“Taping your house key to the front door because you were in a hurry.”
Client code has no secrets
Everything your browser runs, your user can read. The JavaScript bundle is downloaded to their machine; minifying it is not encrypting it. Anything you put in front-end code (a key in a variable, a token in a config, a secret in an environment variable exposed to the client) is visible to anyone who opens developer tools. There is no "hidden" in the browser.
This trips people up because frameworks have a convention that looks like a safety feature but isn't. A prefix like NEXT_PUBLIC_ (or VITE_, or REACT_APP_) doesn't protect a value; it does the opposite. It marks the variable as one that will be baked into the client bundle and shipped to the browser. The prefix means "this is public." An AI tool reaching for it to "fix" an undefined variable is quietly publishing your secret.
Two kinds of keys, and the fatal mix-up
Most services that matter give you two kinds of credential, and the whole game is not confusing them:
- Publishable / anon / client keys: designed to be public. Stripe's publishable key, Supabase's anon key. Safe in the browser because, on their own, they can't do anything privileged (they rely on server rules like RLS to be safe).
- Secret / service / server keys: full power. Stripe's secret key, an OpenAI key, a database service-role key. These can charge cards, spend your model budget, or read every row. They must never touch the browser, a public repo, or a prompt.
Already leaked? Rotate it: you can't un-leak.
If a secret has been in client code, a public repo, a log, or a chat, it is burned. Deleting the line doesn't help: it's still in the git history, in someone's cache, in a scraper's database. The only real fix is to rotate, revoking the old key at the provider and issuing a new one, and then storing the new one correctly. Hiding a leaked key is not a remediation; rotating it is.
# .gitignore: keep secrets out of the repo in the first place
.env
.env.local
.env.*.local
# Already committed once? It's in history. Rotate the key at the provider,
# then scan history so you know what else escaped:
# npx gitleaks detect --source . --redactWhere keys should live
The pattern that holds: secrets live on the server, and the browser asks the server to use them on its behalf. Instead of the browser calling a third-party API with a secret key, it calls your own endpoint, and your server, which the user can't read, adds the key and makes the call. The key never crosses the boundary into code the user controls.
- Server environment variables or a secrets manager: never a file in the repo, never a client-exposed prefix.
- A thin server route that proxies third-party APIs, so the secret key stays server-side (SDEN's bias toward real backends over client-only stacks is partly this).
- Never in the browser bundle, never in git, never pasted into an AI prompt or a shared document.
Once you've internalised "the browser is public," most secret-handling decisions answer themselves: if leaking a value would hurt, it goes on the server, full stop.
In one line each
- Everything in the browser is readable; a NEXT_PUBLIC_/VITE_ prefix publishes a value, it doesn't protect it.
- Publishable keys are safe in the client by design; secret/service keys (Stripe, OpenAI, service-role) never are.
- A leaked secret can't be un-leaked: rotate it at the provider; deleting the line leaves it in git history and scrapers.
- Keys belong on the server: env/secrets manager plus a thin proxy, never in the bundle, git, or a prompt.
Where to go next