Blog Details

blog
about

Read your own production bundle before someone else does

Read your own production bundle before someone else does

Every visitor to your site downloads your JavaScript. That includes the code that talks to your internal API, the config object carrying your analytics key, and, more often than anyone expects, a credential that was supposed to live in an environment variable on a server nobody can reach.

Minification does not help here. It renames variables and strips whitespace. It does not encrypt strings, and strings are where keys live. A three-line search in the browser's devtools, run against the bundle your homepage already served, is usually enough to find out whether you have a problem. This post is about doing that search on purpose, before a crawler does it for you.

The numbers got worse, and the delivery changed

GitGuardian's State of Secrets Sprawl 2026 counted 28.65 million new hardcoded secrets added to public GitHub commits during 2025. That is a 34% rise over 2024 and the largest single-year jump in the five editions of the report. Commit volume grew faster still, reaching roughly 1.94 billion, up 43%, so detections per commit edged slightly down, from about 1.58% to 1.48%.

Two findings in that report matter more than the headline. Credentials for AI services reached 1.27 million, up 81% year over year, and eight of the ten fastest-growing detector types were tied to AI providers, with LLM infrastructure such as orchestration, RAG and vector storage leaking five times faster than the core model providers. Separately, the same research counted 24,008 secrets hardcoded into MCP configuration files on public GitHub, and 2,117 of those still authenticated when tested. Commits produced with Claude Code showed a 3.2% leak rate against the 1.5% baseline, which says less about the tool than about what happens when code gets written faster than anyone reads it.

All of that research is about repositories. A front-end bundle is a different exposure with a nastier property. It is served to every visitor, cached by CDNs and browsers, and copied by anyone who cares to look. A secret committed to a private repo is found when a scanner eventually runs. A secret inlined into a bundle is found by whoever loads the page, including the automated crawlers that do nothing else all day.

How the key gets into the bundle

The mechanism is nearly always the same. A build tool replaces a variable reference with its literal value, and the code that referenced it becomes a file full of strings. That is what exposing an environment variable to the client means in practice. There is no runtime lookup, no server, no gate. The value is written into a static asset and shipped.

The public prefix

Next.js inlines anything prefixed NEXT_PUBLIC_ at next build time. Vite does the same for VITE_, Create React App for REACT_APP_, and most other bundlers have an equivalent. The prefix is the entire security boundary, and it is one prefix too easy to add when a value is not resolving and the docs say this is how you make it available.

The failure is silent, which is what makes it stick. Adding the prefix to a variable that should never have left the server produces a successful build, a successful deploy, and a working feature. No warning, no failing test, no red pipeline. The value is now inside a static asset, and that asset is now in the browser cache of everyone who has visited since the deploy.

The bootstrap blob

Server-rendered apps often hand the client a config object so the first paint does not wait on a second round trip. That object starts small. One afternoon it gains the Stripe publishable key, which is fine, and the Stripe secret key, which is not. Serializing process.env into a script tag, or spreading the whole environment into a template variable because the one field you needed was in there, is how that happens. Nobody reviews the blob because it looks like plumbing.

Source maps in production

A source map reverses the build. Serve one publicly and anyone can reconstruct your original file tree, your variable names, your comments, and your unminified logic. For a leak audit it matters twice over, because a string mangled beyond searching inside the bundle turns back into plain text inside the map.

Most teams serve maps for a reason, and it is a good reason: error tracking needs them to produce a usable stack trace. The fix is to upload the maps to the error tracker at build time and keep them off the public origin. Deleting maps and keeping the errors is the wrong trade, and teams make it because the two requirements lived in different tickets.

The third-party snippet

Tag managers, chat widgets, heatmap tools, A/B testing scripts, review widgets, payment SDKs. Each one ships its own bundle, and each one was installed by copying a snippet from a docs page. Those snippets run with full access to your page, your cookies, and your storage. A tag manager that can inject arbitrary script at runtime is a supply-chain dependency with a login, and its container configuration is a place where credentials quietly accumulate over years.

Not every key in the bundle is a bug

This is where many audits go wrong, in both directions. Flagging every string that looks like a key buries the real finding under forty false positives, and dismissing the whole category because Firebase keys are public misses a database anyone can read.

Firebase's apiKey, Supabase's anon key, Stripe's publishable key, and most map tile and analytics write keys are designed to be public. They identify a project. They do not authorize it. Firebase protects data with security rules, Supabase with row-level security, and Stripe's publishable key can create a payment token but cannot move money or issue a refund.

So the string is not the finding. The finding is what the string unlocks. A Supabase anon key against a table with row-level security switched off is a full data dump from any browser tab. The anon key was never the problem; the missing policy was. The same logic applies to a Firebase project with open read rules, and to the internal API endpoint sitting in your bundle that returns every customer record to an unauthenticated GET.

That reframing changes the work. You are not hunting for strings. You are testing for authorization, and the strings are just the map of where to test.

The audit, in an afternoon

Five steps, in this order, because each one narrows the next.

1. Inventory what the page actually loads

Fetch the page the way a browser does and list every script src. Include the ones injected at runtime by a tag manager, because those will not appear anywhere in your source tree. The network panel in devtools, filtered to JS, gives you the honest list, and it is usually longer than the ticket-estimated one.

2. Pull every bundle and search it

A pattern set worth starting with: sk_live_, sk_test_, AKIA[0-9A-Z]{16} for AWS access key IDs, AIza for Google keys, ghp_ and github_pat_ for GitHub tokens, xox[baprs]- for Slack, -----BEGIN for any embedded private key, eyJ followed by base64 for a JWT, and connection strings starting postgres://, mysql://, mongodb+srv:// or redis://. Add your own internal hostnames ending in .internal or .local, since an internal hostname in a public bundle is a map of a network that was not meant to be mapped.

Search the map files too if any are served. And search the older hashed bundles, because CDNs keep serving previous builds for months and a fixed leak can remain reachable at an old URL long after the deploy that removed it.

3. Check what the map situation is

Look for sourceMappingURL at the end of each file, then request the referenced .map and record the status code. A 200 is the finding. A 403 with a body of HTML usually means the map is absent and the CDN is being polite about it.

4. Call the endpoints the bundle calls

This is the step that produces results rather than a list. Extract every URL the bundle references, pick the ones that look internal or administrative, and request them with no cookie, no authorization header and no referer. Note which ones return data, which return a login redirect, and which return a stack trace. A 200 with records is a breach. A 500 with a framework error page is a smaller but real finding, because it tells an attacker which framework and roughly which version.

5. Check what the browser keeps

Local storage, session storage, IndexedDB, and cookies without HttpOnly. A session token in local storage is readable by any script running on the page, which matters considerably more once the page also runs a tag manager with a dozen vendors in it. Refresh tokens belong in an HttpOnly, Secure, SameSite cookie, and access tokens belong in memory with a short lifetime.

Close the hole, then rotate

Order matters, and the instinctive order is backwards. Rotate first. The string is already public and probably sitting in a browser cache, a CDN edge node and an archive crawl. Removing it from the code and redeploying changes nothing about the credential's validity.

The sequence that actually closes an exposure: revoke and reissue the key at the provider; scope the replacement to the minimum the service needs and set a spend or rate limit; read the provider's usage logs for the window since the leak shipped, assuming it has been read; change the code so the secret lives server-side; purge the CDN and, if it matters, submit a removal request for the archived copy; then re-run the scan and confirm the string is gone from the built output, not just from the source.

That last distinction is the one teams get wrong twice. A scan of the repository proves very little about a bundle, because the leak was created by the build, not by the commit. Source scanning finds the second-order cases, such as a .env file committed by accident. Only scanning the built artifact finds the first-order ones.

Build the guard so it does not come back

Three checks in CI, all cheap, none of them requiring a security team.

  • Scan the built output rather than the source. The step runs after the bundle step and greps the dist directory with the pattern set above, failing the build on a hit outside an allowlist.
  • Keep an explicit allowlist of public prefixes, and treat any new addition to it as a reviewed decision. The list of things allowed to be public should be short enough to read in a pull request.
  • Assert that no source map is present in the deployed artifact. If the error tracker needs maps, the build should upload them and then remove them from what gets served.

Two runtime controls are worth the config. A Content-Security-Policy with script-src limited to the origins you have listed closes the injection path that a compromised tag manager would otherwise open, and Subresource Integrity pins the third-party scripts you load at build time so a changed file gets rejected rather than executed. Neither is exotic. Both require someone to stop adding vendors for a week and write down the list.

What it costs to skip

The failure is rarely dramatic. It shows up as a billing line for a service nobody remembers enabling, or a support ticket from a customer asking why their record changed, or a researcher's email with a screenshot and a request for a fix before publication. Occasionally it is a database dump offered for sale, and by then the question is not whether the key was public but how long it had been.

The common thread in every version is that the team found out from someone else. An audit run in-house finds the same things earlier, while the fix is a deploy and a rotation rather than a notification obligation and a breach letter.

Where to start on a Monday

If you do one thing, do this. Open production in a private window, open devtools, and search every loaded script for the words secret, token, key= and password. Then take the endpoints those scripts call and request them with cookies cleared. Twenty minutes, and it either produces a finding or a considerably better night's sleep.

If it produces a finding, fix the authorization before you fix the string. Rotating a key does nothing about an endpoint that answers without checking who is asking, and that endpoint will still be sitting there long after the new key is deployed.

Send Us a Message