Finding secrets in client bundles: grep the shipped code, then triage
Harvesting keys from front-end JS, sourcemaps, and committed .env files, and the part that matters more than finding them: knowing which key actually bypasses your security model and which is harmless by design.
The browser is an honest informant. Whatever logic runs client-side, you get a
copy of, minified and bundled but fully present. So recon on any web target
starts the same way: pull every script the app loads and treat it as a corpus to
search. The naive version of this (grep for api_key) finds a hundred harmless
strings. The version that pays is knowing which of those hundred strings actually
breaks something, and most don’t.
Where the strings live
Four sources, in rough order of how often they pay out:
- The JS bundles themselves. Minification renames variables, not string
literals. An API key embedded at build time survives minification intact. Pull
every
.jsthe page references, including lazy-loaded chunks the SPA fetches after navigation. - Sourcemaps. A
.mapfile next to a bundle reconstructs the original source, comments and all. Developers strip the bundle from production and forget the sourcemap, or ship it deliberately for debugging. Check for a//# sourceMappingURLcomment at the end of each bundle, then fetch the map. - Committed
.envfiles in deploy sources. Some deploy pipelines serve the build directory wholesale, and a.env,.env.production, orconfig.jsonrides along. Also worth checking: the same file in the public Git history if the deploy source is a repo. - Inline config blobs. A
window.__CONFIG__ = {...}or a hydration payload in the HTML often carries the whole client config object, keys included.
# pull every script the page loads, then search the lot
# grep the corpus for high-signal patterns, not just "key"
sk-[a-zA-Z0-9]{20,} # generic secret-key shapes
eyJ[A-Za-z0-9_-]{10,}\. # a JWT, often a long-lived service token
service_role # the one that matters most, see below
-----BEGIN.*PRIVATE KEY # an actual private key, ship-stopperThe triage that actually matters
Finding a key is the easy 20 percent. The reason most “I found an API key in the bundle” reports get closed as informative is that the key was designed to be public. The whole skill is sorting the harmless from the catastrophic.
The clearest example is the publishable-key versus service-role-key split that modern BaaS platforms hand teams. Both look like opaque tokens. One is on every page by design: it can only do what the database’s row-level security policies permit, so it’s effectively the identity of “an anonymous visitor.” The other, the service-role key, is built to run trusted server code and bypasses row-level security completely. A team that pastes the wrong one into their front-end config has shipped a key that reads, and often writes, every row in every table, no matter what their policies say.
So when I pull a key out of a bundle, the first question is which side of that line it sits on. The way you tell, without doing anything destructive: a publishable key, used against the data API, returns only what an anonymous user should see, because the policies still apply. A service-role key returns rows that should be invisible to anyone, because it ignored the policies. One read-only request that returns data an anonymous user provably cannot see is the entire proof.
How I run it
The flow that keeps this efficient instead of a string-grep swamp:
- Spider every script and map the SPA loads, plus the inline config. Build one searchable corpus.
- Grep for high-signal shapes (private keys, service-role markers, long-lived JWTs, cloud credential formats), not the word “key.”
- For each candidate, classify: public-by-design or privileged. Discard the former immediately.
- For a privileged candidate, do one non-destructive read that demonstrates access an unprivileged caller can’t have. Stop there.
That last step is what turns a grep hit into a report a triager can’t wave away. The transferable lesson is that secret-hunting is mostly a classification problem, not a search problem. The strings are easy to find; the judgement about which one bypasses the security model is the whole job, and it’s the difference between an “informative” close and a critical.