Secrets in public code are rarely a discipline failure. They are a plumbing failure. A build tool inlines an environment variable it should have kept on the server. A config file gets committed before anyone adds it to .gitignore. A deployment copies the whole working directory, including the .git folder.
The result is the same in every case: a credential that should have stayed private is now readable by anyone who looks, and increasingly by automation that does not need to look very hard.
The three routes that account for most of it
1. Bundled into the frontend
Modern build tools substitute environment variables into client-side code at build time. That is the intended behaviour - it is how a public API endpoint or a feature flag reaches the browser. The trouble comes when a variable holding a private key follows the same path.
Frameworks try to prevent this with naming conventions: only variables prefixed NEXT_PUBLIC_, VITE_, REACT_APP_ and so on get inlined. The convention works right up until somebody adds the prefix to make a build error go away.
Everything in a client-side bundle is readable by every visitor. There is no minification, obfuscation or environment-variable indirection that changes this - the browser must be able to read the value in order to use it, and so can anybody else.
2. Committed to a repository
A .env file, a service-account JSON, a key pasted into a config while debugging and forgotten. If the repository is public, that value is public. If the repository later becomes public, every value in its history becomes public at that moment, including ones deleted long ago.
3. Deployed to a web-reachable path
The quiet one. A deployment process copies a directory wholesale and the web server serves whatever it finds:
/.git/- a deployed Git directory allows the entire repository, including full history, to be reconstructed by anyone./.env- served as plain text unless explicitly blocked./config.json,/backup.sql,/.aws/credentials- anything that was in the folder at deploy time.
These are checked automatically by anyone scanning at scale, because the paths are predictable and the payoff is high.
Why "nobody will find it" is not a plan
Automated discovery is the norm now, not the exception. Public code hosting is continuously scanned - by the platforms themselves, by defensive services offering leak detection, and by people looking for credentials to use. Several cloud providers run their own scanning and will proactively revoke a key they find published.
The practical consequence: the window between publishing a secret and it being found is not something you can rely on. Assume any credential that has been publicly readable, for any length of time, is compromised.
Finding what you are currently exposing
Start with what a stranger can reach, because that is the most urgent category.
# sensitive paths - 200 is bad, 403 or 404 is what you want
for p in /.git/HEAD /.env /config.json /backup.sql; do
printf '%-18s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "https://yourdomain.com$p")"
done
# key-shaped strings in your own bundle
curl -s https://yourdomain.com | grep -oE '(AKIA[0-9A-Z]{16}|sk_live_[0-9a-zA-Z]{24,}|AIza[0-9A-Za-z_-]{35}|ghp_[0-9A-Za-z]{36})'
Then your repository history, which needs a dedicated tool - gitleaks and trufflehog are the common ones - because manual inspection of history does not scale and misses the file deleted three years ago.
One caveat on pattern matching: it finds credentials with recognisable formats. AWS access keys, Stripe live keys and GitHub tokens all have distinctive prefixes. A database password, an internal HMAC secret or a bare hex string does not, and no scanner will spot it. Pattern-based detection is a floor, not a guarantee.
Which keys may be public, and which may not
Not every credential is a secret. Some are designed to sit in a browser, and confusing the two categories in either direction causes problems - either a real secret gets shipped, or an engineer spends a day trying to hide a value that was always meant to be visible.
| Safe in the browser | Never in the browser |
|---|---|
| Publishable / public API keys | Secret or private API keys |
| Anonymous keys protected by server-side access rules | Service-role or admin keys that bypass those rules |
| Public site keys for bot-protection widgets | The matching server-side verification secret |
| Analytics measurement IDs | Database connection strings |
The distinction is what the key can do when held by a stranger. A publishable key that can only perform operations you would allow any visitor to perform is fine in a bundle. A key whose entire purpose is to bypass authorisation is not, regardless of how well it is hidden.
The dangerous case is a pair that looks like one thing: platforms commonly issue an anonymous key and a service-role key that are similar in shape and sit adjacent in the dashboard. Copying the wrong one into a frontend environment variable is a single-character mistake with an unlimited blast radius.
Stopping it before the commit
Detection after publication is damage control. The cheaper intervention is earlier in the pipeline.
- A pre-commit hook running a secret scanner over staged changes catches the value before it ever enters history.
gitleaks protect --stagedis a common one-liner for this. - CI scanning as a second net, because hooks live on developer machines and a machine without them will eventually push.
- Provider-side push protection, offered by the major code hosts, which rejects pushes containing recognisable credentials.
- A committed
.env.exampleholding key names with empty values, so nobody is tempted to commit the real file for reference.
None of these catch a secret with no recognisable format. They do catch the large majority of real incidents, which involve credentials from major providers with distinctive prefixes.
When one has leaked
The order matters, and the first step is the one people skip in the rush to hide the evidence.
- Rotate the credential. Immediately, before cleanup. Until the old value is revoked, nothing else you do reduces the risk.
- Check for use. Review access logs and billing for activity you did not authorise. Cloud provider costs are often the first visible symptom of a stolen key.
- Clean up the exposure. Remove the file, purge it from history, block the path at the web server.
- Fix the pipe. A leak that happened once through a build process will happen again unless the process changes. Add the pattern to
.gitignore, add secret scanning to CI, and move the value into a secrets manager.
Rewriting history feels like the fix because it makes the evidence disappear. It does not help. Clones, forks, CI logs, caches and anyone who already fetched the value are all unaffected by a force push.
Keeping them out
- Secrets belong in a secrets manager, injected at runtime - not in files, not in the repository, not in the build environment for client code.
- Block sensitive paths at the web server so a deployment mistake does not become a public one.
- Run secret scanning in CI so the check happens before the merge rather than after the leak.
- Prefer short-lived credentials. A token valid for an hour is a far smaller problem than a static key valid until someone revokes it.
- Treat public keys as public. If a value must reach the browser, use a key type designed for that, scoped so that possessing it grants nothing dangerous.
Where this sits
Exposed secrets are the one item on an external report where a single finding can be genuinely serious on its own. A missing header is a missing layer; a live AWS key in a public bundle is an open door.
That asymmetry is worth keeping in mind when reading any report, including ours. Most findings are about hygiene and accumulate into a picture. This one occasionally is not - it is a specific credential, in a specific file, that needs rotating today. The rest of the surface is covered in external security posture.