ShipSafe

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.

The distinction that matters

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".

§ 01

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.

The keys we most often find this way

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.

§ 02

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
Run in your project root. Then read each result and ask, for each one: would I post this value publicly?

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
Prefixes worth searching your live bundle for. Any hit is a secret in public.

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.

§ 03

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Do not skip step one

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.

§ 04

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.
Paste into Cursor, Claude Code or Codex, after you have already revoked the exposed key.

Step five is the part people leave out, and it is the one that finds the second leaked key.

§ 05

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.

Questions people ask

Short answers, no hedging

Is NEXT_PUBLIC_ safe to use for a secret?
No. Next.js inlines every variable prefixed NEXT_PUBLIC_ into the JavaScript it serves the browser, which is the entire purpose of the prefix. There is no configuration that keeps one of them private. If a secret carries that prefix, it has already been published to everyone who loaded the page.
How do I check which NEXT_PUBLIC variables my app ships?
Build the app and search the output for the prefix: grep -r "NEXT_PUBLIC_" .next/static finds every value that was inlined. Read the values, not just the names, because the risk is in what a variable holds rather than what it is called.
I already deployed a secret in NEXT_PUBLIC_. What do I do first?
Rotate the credential at its provider before you change any code, because the old value is already public and rewriting your source does not recall it. Then check the provider's logs for use you cannot account for, move the call into a route handler or server action, and redeploy without the prefix.
Does removing the variable from my repository fix it?
No. The value was compiled into bundles that have already been served, and old builds, caches and forks can retain them. The credential itself has to be replaced; the code change only stops the next build from leaking the new one.
Run the check on your own app

Stop reading about it. Point us at your URL.

The free Preflight is passive and read-only. It checks your app for exactly the exposures this guide describes and returns a score, a verdict and one finding with its evidence. No signup, no card.

Keep reading