Research / Secrets & exposure
Your secrets are in the browser
The most common critical finding in our work is not exotic. It is a production key shipped inside front-end code, where anyone with a browser can read it.
If we had to bet on the single most likely critical finding in a company we have never assessed, we would bet on a live credential sitting in front-end code. Not a clever chain. A cloud storage key, a pipeline token, or a source-control token, compiled into a JavaScript bundle and served to every visitor.
It keeps happening because the mistake feels safe at the moment it is made. The secret goes into an environment variable, gets bundled by the build tool, and disappears into a minified file with an opaque name. It looks hidden. It is not hidden. It is published.
The system assumption
Front-end code runs on the user's machine. Every byte a browser needs to run your application, it must first receive, which means every byte is readable by whoever is holding the browser. This is not a bug in the web. It is what the web is. A single-page app is a program you mail to strangers.
The mistaken assumption is that the build step is a hiding place. A value read from an environment variable and inlined during bundling feels like it went somewhere private, because it did on the build server. But the output of that build is downloaded by the client. "Minified" is not "encrypted." A secret in a bundle is a secret in a text file that you serve, on request, to anyone.
Where the assumption breaks
We find the same pattern in three places, in rough order of how often:
- Front-end bundles. Cloud object-storage access keys and secrets, data-pipeline API tokens, and third-party service keys, compiled straight into the JavaScript. In more than one assessment a single bundle held keys granting read and write to a production storage bucket full of customer data.
- Public source repositories. Keys and access tokens committed to a repo, often in config, test fixtures, or a file that was never meant to ship but never got removed from history.
- DevOps tooling leaked into the client. This is the worst version. A front-end build that pulls in source-control, code-quality, and artifact-registry tokens, along with internal project names and pipeline URLs, and ships them to the browser. Now the leak is not one bucket. It is a foothold into how the software is built.
The values are usually easy to recognize. Access keys and tokens have distinctive prefixes and shapes. Finding them is a text search, which is exactly why an attacker will find them too.
Reconstructing it
The whole exploit, in synthetic form, is "view source and read." Nothing is bypassed.
# 1. Fetch the app's own bundle. It is public by definition.
curl -s https://app.example.test/assets/index.min.js > app.js
# 2. Grep for the usual shapes.
grep -oE '(AKIA[0-9A-Z]{16}|Bearer [A-Za-z0-9._-]{20,}|secret[A-Za-z]*=)' app.js
# BUCKET="example-prod-assets"
# accessKeyId="AKIAEXAMPLEEXAMPLE00"
# secretAccessKey="<redacted-placeholder>"
# 3. Use them the way the real service would.
# Read/write access to the production bucket, with no exploit at all.
There is no vulnerability being triggered in the usual sense. The application shipped the key; the attacker used it. When a leaked key carries write access, this stops being disclosure and becomes control: an attacker can alter what the storage serves, which for a bucket of front-end assets or user uploads is its own second-stage attack.
Why the obvious defenses didn't solve it
Obfuscation and minification. They rename variables and strip whitespace. The literal string of the secret survives intact, because the running code needs it. Obfuscation raises the effort of reading logic by a little and hides a secret by nothing.
"It's only the dev key." The most damaging multiplier we find is credential reuse across environments. When the same key and secret protect development and production, a leak from the low-value, loosely-guarded dev build is a leak of production. The environments were separated on the org chart and joined at the credential. One key erased the boundary.
A private-looking origin. Teams sometimes rely on a dev host being unadvertised, or reachable only by direct IP, as if that were access control. It is not. A host that answers the internet is discoverable, and a bundle it serves is public whether or not the host has a friendly domain name.
Root cause
The root cause is a boundary error: a secret was placed on the wrong side of the line between server and client. Anything the browser can execute, the browser's owner can read. So the rule is not "hide the secret better in the front end." There is no better. The rule is that the secret never crosses into front-end code at all. The client asks a server it trusts to perform the privileged action; the server holds the credential and never emits it. If the front end appears to need a secret, the design needs a backend endpoint, not a hidden string.
How to test for this class of failure
- Pull every served JavaScript bundle, source map, and inline script, and grep for known key shapes and prefixes, for
secret,token,apiKey,password, and for base64 blobs that decode to credentials. - Check source maps specifically. When shipped to production they can reconstruct original source, comments and all, and comments are where secrets hide.
- Scan repository history, not just the current tree. A key removed in the latest commit is still in the history a clone provides.
- Where a key is found, and where you have authorization to confirm impact, check what it actually grants and whether the same value works against more than one environment. The reuse is where medium becomes critical.
- Automate it so it runs continuously. New bundles ship every deploy, and a secret can be introduced by any of them. A one-time scan certifies one moment.
How to design the control correctly
- Keep secrets server-side, full stop. Privileged operations run behind an endpoint that holds the credential. The client gets a result, never the key.
- Scope and separate credentials. One credential per environment and per purpose, each with the minimum rights it needs. Never share a key between production and anything else. Then a leak is bounded to exactly one small thing.
- Make leaks cheap to survive. Short-lived, rotatable credentials over long-lived static ones, so rotation is a routine action and not a crisis. Assume every secret will eventually leak and design so that when one does, it expires and is replaced without drama.
- Block at the build. Run secret scanning in the pipeline and in a pre-commit hook, and fail the build on a hit. The cheapest place to catch a secret is before it is committed; the most expensive is after it is served.
- Do not ship source maps to production, or restrict them, so you are not publishing your original source and comments alongside the bundle.
What we took away from it
This finding is unglamorous, and that is the point. It is not a subtle logic flaw or a novel technique. It is a production key in a public file, and it is, in our experience, the most likely serious problem a given company has right now. The security industry spends a lot of attention on sophisticated attacks. The attacker spends five minutes reading your JavaScript.
It is also the finding that best fits how we think a security product should behave: it is common, it is confirmable, and it has a clear fix. You do not need a red team to know whether your bundles contain a live key. You need to look, keep looking on every deploy, and make sure that the day a secret does leak, it is scoped so small and rotated so fast that reading it buys the attacker almost nothing.
References
- OWASP ASVS v4: V6 Stored Cryptography & V14 Configuration
- CWE-798: Use of Hard-coded Credentials
- CWE-615: Inclusion of Sensitive Information in Source Code Comments
- OWASP: Secrets Management Cheat Sheet
- NIST SP 800-53 Rev. 5, IA-5 Authenticator Management
Internal evidence: Kahu Labs Research, anonymized authorized assessments and disclosures (2023, 2025). Client, target and identifying details are withheld; see our research policy.