Here's how secret keys end up in JavaScript every visitor downloads, and how to get them out.
A vibe-coded app often has one environment file and one impatient afternoon. The model needs a key, the fetch is written inside a client component, and the fastest way to make the red squiggle go away is NEXT_PUBLIC_OPENAI_API_KEY or a string literal. Next.js does what you asked. The value is inlined into the browser bundle. View Source is the whole attack.
This is not a sophisticated leak. It is the app handing out credentials because the framework treated them as configuration.
What "public" means
In Next.js, only variables prefixed with NEXT_PUBLIC_ are available in client components. That prefix is not a suggestion. It is a publish switch. The same idea shows up in Vite as VITE_, in Create React App as REACT_APP_, and in any setup that bakes env into a static file at build time.
Server components, Route Handlers, and Server Actions can read process.env.OPENAI_API_KEY without the prefix. The browser never sees that value, unless you pass it across as a prop or return it from an API you wrote. Both of those are the same bug with extra steps.
A useful rule: if the key can spend money, send email, move a charge, or skip an access check, it does not get a public prefix and it does not appear in a file under use client.
The keys people mix up
Stripe. pk_live_ and pk_test_ are publishable. Stripe.js expects them in the browser. sk_live_, sk_test_, rk_live_, and whsec_ are not publishable. A secret key in the bundle can create charges and read customers. A webhook secret in the bundle lets someone forge events your server will trust. Test-mode secret keys are not harmless. They still reach your Stripe test data, and the habit is how live keys ship next.
OpenAI and Anthropic. sk-proj-… and sk-ant-… are billing credentials. A button that calls the vendor from the browser with the raw key will be found, and the bill arrives later. The browser should call your route. The route holds the key, checks who the user is, and rate-limits the call.
AWS. An access key id (AKIA…) in client code is a bad sign even though the id is not the secret by itself. The secret is often a few lines away, or in the same env object the model copied. The well-known example AKIAIOSFODNN7EXAMPLE is documentation, not a credential. Anything else should be deactivated in IAM if it ever touched a client bundle.
Private keys. A PEM block (BEGIN PRIVATE KEY) in a JavaScript file is already compromised. Generate a new key. Do not paste the old one into a chatbot to ask what it was for.
Where to look
You do not need a scanner to do the first pass, though a scanner will look in the places you forget.
- The page source, especially inline
<script>tags where Firebase and Supabase configs get pasted. - Built files under
.next/static/chunks/ordist/assets/. Search the built output, not onlysrc/. A key that is gone from source and still in last week's deploy is still public. - Source maps, if you uploaded them to the public host. A
.mapfile next to a chunk is your original source, comments included. - The network panel. If the browser sends
Authorization: Bearer sk-…toapi.openai.com, the key is not on the server.
Search the repo for sk_live_, sk-proj-, sk-ant-, sb_secret_, service_role, AKIA, BEGIN PRIVATE KEY, and NEXT_PUBLIC_ next to any of those names.
Rotation is the fix, not just deletion
Removing the line stops the next deploy from publishing it. The copy that already shipped still works until you revoke it. Order of operations:
- Revoke or roll the key in the vendor dashboard. Assume the old value is known. Do not print it in the commit message or the chat.
- Delete it from client code, from public env vars, and from any sample
.envthat was committed with a real value. - Put the new key in a server-only variable on the host. If your app needs a real server to hold secrets, hosts like Railway let you set private variables in a dashboard (referral link: we may earn a cut, it costs you nothing).
- Add a check that fails CI if a client chunk contains the prefixes above. A one-line
grepin the build output is enough to start. It will false-positive less often than you fear if you stick to the prefixes with a minimum length.
If the key was committed, removing it from HEAD is not enough. It is in history. Rolling the key matters more than rewriting git. Rewrite history only if you know who has clones, and still roll the key.
A prompt you can paste
Search this repo and the production bundle for secret keys that must not ship to the browser: sk_live_, sk_test_, rk_live_, whsec_, sk-proj-, sk-ant-, sb_secret_, Supabase service_role JWTs, AKIA access key ids, and PEM private keys. Remove them from client components and from any NEXT_PUBLIC_ or VITE_ variable. Tell me which provider keys I need to rotate, without printing the secret values. Move the calls to a server route that reads a non-public env var, and add a CI grep so a client bundle containing those prefixes fails the build.