HomeFeaturesDocumentationContactDownload

How API keys end up in your client bundle

Why secrets reach public JavaScript bundles, how to grep a built bundle for them, and what to do in the right order after one has already shipped.

The first time I found a live API key in a production bundle, it was not hidden. It was in a variable called apiKey, in a file the browser had downloaded a few hundred thousand times.

Nobody made a dramatic mistake. Somebody added an integration, needed the key in a component, and the build did exactly what it was told.

This is one of the few security problems where the mechanism is boring and the cost is not. Here is how the key gets there, how to find one, and what to do about it in an order that actually helps.

The four ways it happens

The prefix did its job. NEXT_PUBLIC_, VITE_, PUBLIC_, REACT_APP_: every modern build tool has a naming convention that means “inline this value into the client bundle.” That is a good design. It is also a foot-gun, because the fix for “my environment variable is undefined in the browser” that you will find in ten minutes of searching is to add the prefix. It works immediately. It also publishes the value.

The file was copied. .env.example becomes .env, .env gets committed once, or a config file that reads server-side values is imported by a component and the bundler follows the import. Tree shaking does not save you here: a string that is referenced is a string that ships.

The vendor said it was fine. Some keys are genuinely publishable. Analytics site IDs, Stripe publishable keys, Mapbox tokens with URL restrictions. The trouble is that vendors ship pairs, the names are similar, and sk_ versus pk_ is one character of attention at 6pm. A publishable key with no domain restriction configured is also a different thing from a publishable key.

An agent wrote it. This one has grown. Ask a coding agent to wire up an integration, and it will produce working code. Working code means the request succeeds, and the shortest path to a successful request from the browser is putting the key in the browser. The agent does not know which of your keys is server-only unless something in the repository tells it. This is the same class of problem as the ones in the AI-generated code audit: the output is plausible, compiles, and passes the demo.

Grep the built bundle, not the source

Source-level searching misses the interesting cases, because the whole question is what the build produced. Build first, then search the output.

npm run build
grep -rEn '(sk|rk|api|secret|token)[_-]?(live|test|key)?[_-]?[A-Za-z0-9]{16,}' dist/ | head -50

That pattern is deliberately loose and will produce false positives on hashed asset names and minified identifiers. Widen or narrow it, but start loose. The alternative failure mode, a tight regex that finds nothing and reassures you, is worse.

For known vendor shapes, search for the prefixes directly:

grep -rEon 'sk_live_[A-Za-z0-9]{8}|sk_test_[A-Za-z0-9]{8}|AKIA[0-9A-Z]{8}|ghp_[A-Za-z0-9]{8}|xox[baprs]-[A-Za-z0-9]{8}' dist/

Truncating the captured length means your terminal history and any log of this command does not itself become a place the secret lives.

A few things worth checking beyond the JavaScript:

  • Source maps. If dist/**/*.map ships to production, your original source is public, including comments that explain what the key is for.
  • The HTML. Server-rendered pages inline state into a <script> tag, and that serialized blob is a common hiding place for a whole config object.
  • Service worker and manifest files, which are often generated by a separate plugin that does not respect your environment rules.
  • Anything in public/ or static/, which is copied verbatim without a build step to inspect it.

Then check what the deployed site actually serves, which is not always what your local build produced:

curl -sS https://example.com | grep -oE '<script[^>]*src="[^"]+"'

Fetch each one and search it the same way. A stale asset on a CDN can outlive the commit that removed the key by a long time.

After you find one, order matters

The instinct is to delete the line and push. That is the third step, not the first.

Rotate first. The key is public and has been for as long as the bundle has been live. Assume it is compromised, because a removed key that is still valid is still a live credential. Generate the replacement, deploy it to the server side, then revoke the old one. Doing it in that order avoids an outage in the middle of an incident, which is when you least want one.

Then check what was done with it. Vendor dashboards usually have request logs with source IPs and timestamps. Look for usage from outside your infrastructure, spikes that do not match your traffic, and calls to endpoints your application never uses. If the key had write access or billing implications, this step is the one that determines whether you are writing a changelog entry or a disclosure.

Then fix the code. Move the call server-side. Your frontend hits your endpoint, your endpoint holds the key and calls the vendor. This also gives you a place to put rate limiting and authorization, which the direct-from-browser version never had.

Then deal with git history. If the key was committed, removing it from the working tree does not remove it from the repository. History rewriting is disruptive and, on a public repository, arrives after every clone and fork already has a copy. Rotation is what makes the exposure harmless. Treat history cleanup as tidying, not remediation, and do not let it delay the rotation.

Then invalidate the cache. A CDN can keep serving the old bundle after the new one deploys. Purge it and re-fetch the deployed asset to confirm.

Making it not happen again

Three things, in order of how much they actually help.

A build-time check that fails. Search the output for your vendors’ secret prefixes and exit non-zero on a match. This is the one that works, because it is not optional and it runs on the artifact rather than the intent. Wire it into the same place as your other release gates, next to the pre-commit hooks that catch the cheaper mistakes earlier.

A naming rule people can follow without thinking. Server-only values get a name that is obviously wrong in a client file. If your secrets are SERVER_STRIPE_SECRET_KEY and your publishable ones are PUBLIC_STRIPE_PUBLISHABLE_KEY, the mistake becomes visible in a diff instead of requiring someone to remember which prefix means what.

Restrictions on the keys that must be public. Publishable keys are not a free pass. Set the HTTP referrer allowlist, the origin restriction, or the scope limit the vendor offers. A publishable key with no restrictions is a rate-limit-free proxy for anyone who finds it.

If you use coding agents, write the rule down where they will read it. A line in AGENTS.md or CLAUDE.md saying which variables are server-only, and that browser code calls your own endpoints rather than vendor APIs directly, changes what gets generated. It is a cheaper intervention than reviewing every diff for it, though you should still verify the patch before merging.

SiteCMD’s source scan looks for secret-shaped values in built output and committed configuration, which covers the mechanical part. The judgment call that no scanner makes for you is which of your keys were ever meant to be public, and that answer belongs in the repository rather than in one person’s memory.