Secrets · 8 min · Updated 28 Aug 2026
NEXT_PUBLIC secrets exposed: did you leak a key in your Next.js app?
NEXT_PUBLIC_ means public. Here is how to check what yours are carrying, and the order to fix it in if one is a secret.
Next.js has one rule about environment variables and it is not subtle: a variable whose name starts with NEXT_PUBLIC_ is inlined into the JavaScript sent to the browser at build time. Everything else stays on the server.
"Inlined" is the word that catches people out. The value is not fetched at runtime and not hidden behind an API — it is substituted into your compiled code as a literal string. It ships to every visitor, it is cached on the CDN, and it is in the bundle of every deploy you have ever made. There is no setting that undoes that after the fact.
This is not a Next.js flaw, and a public variable is not automatically a problem. A Stripe publishable key, a Supabase anon key, a PostHog project key and your own API URL all belong in the client. The question is never "is it public" — it is "is this one supposed to be".
Why a coding agent does this to you
The mechanism is worth understanding, because it tells you where to look. You ask an agent to make a feature work. It writes a fetch on the client. The fetch needs a key. The key is in .env without a prefix, so on the client it is undefined and the feature fails.
The agent now has two options. The correct one is to move the call into a route handler or a server action, so the key stays on the server. The fast one is to add NEXT_PUBLIC_ to the variable name, at which point the feature works immediately and every test passes.
Agents take the fast one often, and nothing in the loop objects, because from the outside the two solutions are indistinguishable: the feature works either way. The difference only shows up when someone reads your bundle.
An OpenAI or Anthropic key behind an AI feature, a Resend or SendGrid key behind a contact form, a Supabase service_role key added when RLS blocked a query, a Stripe secret key (sk_live_) added when a publishable key would not create a session, and Twilio or AWS credentials. Each of these is a spending account or a full-access database credential that anyone can copy out of your page source.
Check your app in three minutes
Start with the list, not the bundle. In your project root, read every public variable you have declared — this is faster and more complete than searching compiled output.
grep -rn "NEXT_PUBLIC_" .env* --include=".env*" 2>/dev/null grep -rn "NEXT_PUBLIC_" app/ src/ pages/ components/ lib/ 2>/dev/null | grep -v node_modules # also check what your host has, since a variable can exist there # and never appear in your repo at all: vercel env ls
That last line matters more than it looks. A public variable added through the Vercel dashboard, during a late-night debugging session, exists in your production bundle and nowhere in your source. It is the single most common way a leaked key survives a source review.
Then confirm against what actually shipped. Open your live site, view source, and search the loaded JavaScript for the prefixes that matter.
sk_live_ sk_test_ Stripe secret key sk- sk-proj- OpenAI sk-ant- Anthropic re_ Resend SG. SendGrid AKIA AWS access key service_role Supabase admin key (a JWT: look for "role":"service_role") eyJ any JWT — decode it and read its role claim -----BEGIN a private key of any kind
A Supabase anon key also starts with eyJ and is fine in the client. Decode the token and read the role claim: anon is expected, service_role is an emergency.
If you found one, do these five things in order
Order matters here, and most people get it wrong by starting with the code. The key is already public and has been for as long as that deploy has been live. Nothing you change in your repository makes the copy in someone's cache stop working.
- 01Revoke the key first. Not rotate, not remove from the code — revoke it at the provider, so the exposed value stops working. Everything else is second.
- 02Read the usage log. Check the provider's activity for calls you did not make. For a spending key, check the invoice. This is how you find out whether it was found before you found it.
- 03Issue a new key, server-side only. Name it without the
NEXT_PUBLIC_prefix. Add it in your host's environment settings, not committed to the repo. - 04Move the call to the server. A route handler under
app/api/or a server action. The client calls your endpoint; your endpoint calls the provider. Add the auth check the browser call never had, and a rate limit if it does anything that costs money. - 05Redeploy, then confirm the bundle is clean. Load the live site again and search for the prefix. Absence in your source is not absence in production.
The most expensive version of this mistake is fixing the code first and revoking the key in the morning. Bundles are crawled continuously, and a live AI or cloud key is the most valuable thing on a small site. Revoke, then tidy.
The prompt that fixes it properly
If the agent that created this is still your fastest way to change the code, give it an instruction that closes the actual gap rather than moving the string around. This is roughly the remediation prompt we attach to this finding class in a report.
The value in NEXT_PUBLIC_<NAME> is a secret credential and must not reach the browser. Do all of the following: 1. Create a server-only route handler (app/api/<feature>/route.ts) that performs the provider call using process.env.<NAME> without the NEXT_PUBLIC_ prefix. 2. Require an authenticated session in that handler and return 401 otherwise. Add a per-user rate limit if the call costs money. 3. Change the client to call our own endpoint. Do not pass the key from the client in any form: not a header, not a query parameter, not a request body field. 4. Delete every NEXT_PUBLIC_<NAME> reference from the codebase and from .env files, and never read the secret in a client component. 5. Show me every remaining NEXT_PUBLIC_ variable with a one-line justification for why each is safe to publish.
Step five is the part people leave out, and it is the one that finds the second leaked key.
Then prove the bundle is clean
A secret removed from your repository is not a secret removed from your site. Between the two sits a build, a deploy, a CDN cache and possibly an environment variable set in a dashboard you have forgotten about.
So finish the way you started: fetch your live JavaScript and search it for the prefix again. If the string is gone, the finding is closed, and you can say so with evidence rather than intention.
That is the same standard we hold ourselves to. We do not mark this finding fixed because the code changed — we re-download the bundle and look for the key again.