NEXT_PUBLIC_ env variable exposed a secret? How to check and fix

Next.js copies every NEXT_PUBLIC_ value into the JavaScript visitors download. How to find which secrets your live site ships and move them to the server.

· 7 min read

You asked Cursor or Claude Code to wire up a payment, an AI feature or a database call. It worked locally, then broke in the browser with undefined, and the fix that made it work was renaming STRIPE_SECRET_KEY to NEXT_PUBLIC_STRIPE_SECRET_KEY. The feature works. The key is now in a JavaScript file that every visitor downloads.

This is one of the most common leaks in Next.js apps, and it is easy to miss because nothing looks broken. This guide explains what the prefix actually does, how to find out which values your live site ships, and how to move a secret back to the server so it stays there.

What NEXT_PUBLIC_ actually does

By default, environment variables in Next.js exist only on the server. The Next.js documentation describes the prefix plainly: at next build, every reference to process.env.NEXT_PUBLIC_SOMETHING is replaced with the literal value, and that value is inlined into any JavaScript sent to the browser.

Three consequences follow, and all of them matter when you clean up.

  • It is a copy, not a reference. After the build, the value sits in a static file under /_next/static/. Minification renames your variables but keeps string values intact, so the key is readable as-is.
  • It is frozen at build time. Changing the variable in your hosting dashboard does nothing to bundles that were already built. You need a new build, and older deployments may still serve the old files.
  • Hosting settings do not protect it. Vercel lets you mark a variable as a Secret, which makes its value write-only in the dashboard and redacts it from build logs (Vercel docs). That controls who on your team can read it. It cannot stop Next.js from writing a NEXT_PUBLIC_ value into the bundle, because inlining is exactly what the prefix asks for.

Variables without the prefix behave the other way: in client code, Next.js replaces them with an empty string. That is the undefined that started the whole problem.

How a secret ends up in the browser

AI assistants rarely leak a key on purpose. They fix the error in front of them, and the shortest fix often crosses the server–client line. The usual routes:

  1. The prefix as a bug fix. A component marked "use client" reads process.env.OPENAI_API_KEY, gets an empty value, and the assistant adds NEXT_PUBLIC_ to make it work.
  2. A shared helper. lib/stripe.ts creates a client with the secret key. It is fine while only route handlers import it. Then a client component imports something else from the same file, and the whole module joins the client bundle.
  3. A prop from a server component. A server component reads a secret correctly and passes it, or a config object containing it, as a prop to a client component. Props passed to client components are serialized into the page data that the browser receives, as the Server and Client Components guide explains. No prefix involved, same result.
  4. A copied .env file. Someone pasted the local .env.local into the hosting dashboard, including a variable that was prefixed during an experiment and never renamed back.

Not every NEXT_PUBLIC_ variable is a problem. A Supabase URL and anon or publishable key, a Stripe publishable key pk_live_…, an analytics ID or a Sentry DSN are meant to be public. The question is only whether the value lets someone spend your money or read data they shouldn't. The anon vs service_role article covers the Supabase case in detail.

Check it yourself

Do all three steps. The repository tells you what could leak; the live site tells you what did.

1. List every public variable in the code

From the project root:

grep -rhoE 'NEXT_PUBLIC_[A-Z0-9_]+' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='.env*' . \
  | sort -u

Read the list slowly. Any name containing SECRET, PRIVATE, SERVICE_ROLE, TOKEN, PASSWORD or a provider name next to KEY deserves a second look. Then do the same in your hosting dashboard's environment variable list, which may contain names that no longer appear in code but are still set.

2. Search a production build

Build locally with the same variables production uses, then search the client output:

npm run build
grep -rlE 'sk_live_|rk_live_|sk-proj-|sk-ant-|sb_secret_|service_role|AKIA[0-9A-Z]{16}|ghp_|github_pat_|BEGIN PRIVATE KEY' .next/static

Any file it prints is a file the browser can download. To catch values that don't have a recognizable prefix, such as a database password or an internal webhook secret, search for the first few characters of the actual value instead.

3. Search the live site

Your build machine and production can differ, so check what is actually deployed. Open the site in Chrome, open DevTools, tick "Disable cache" on the Network tab and reload. Click through the pages that use the feature you are worried about, so lazily loaded chunks load too. Then press Cmd+Option+F (Mac) or Ctrl+Shift+F (Windows) and search all loaded sources for the same prefixes as above.

Also open View Source on a page that renders the feature and search the HTML. With the App Router, the data passed to client components is embedded in the page itself, so a secret passed as a prop shows up there, not in a .js file.

For a longer walkthrough of searching bundles and source maps, see how to find API keys leaked into your JavaScript bundle.

Fix it: revoke, then move the call

The order matters.

  1. Revoke or roll the key at the provider. The value has already been served to every visitor and cached. Renaming the variable does not un-publish it. If it was an OpenAI key, the OpenAI leak checklist covers checking usage.
  2. Rename the variable without the prefix in .env.local and in every hosting environment: production, preview and development.
  3. Move the code that uses it to the server. A route handler in app/api/…/route.ts, a server action, or a server component that never passes the value down. The browser calls your endpoint; your endpoint holds the key.
  4. Mark server modules as server-only. Add import 'server-only' at the top of any file that reads a secret. If a client component ever imports it, the build fails instead of silently shipping it. This is documented in the Next.js guide linked above.
  5. Check what you pass as props. A client component should get the result of a call, not a client object or config built with the key.
  6. Rebuild and redeploy, then repeat steps 2 and 3 of the check on the live site.

A prompt that works with Cursor, Claude Code or Lovable:

A secret is exposed to the browser through NEXT_PUBLIC_[NAME].
I have already revoked the old value.

1. Rename the variable to [NAME] (no NEXT_PUBLIC_ prefix) everywhere
   it is read, and list the hosting environments I need to update.
2. Move every use of it into server-only code: a route handler under
   app/api/ or a server action. Add `import 'server-only'` to each
   module that reads it.
3. Change the client components to call that endpoint and receive only
   the result. Do not pass the key, or any object built from it, as a
   prop to a component marked "use client".
4. If the feature is for logged-in users, check the session in the
   endpoint before calling the provider.
5. Run `npm run build`, then search .next/static for the old variable
   name and the provider's key prefix, and show me the result.
List every file you changed.

Review the diff before you deploy. Assistants sometimes "fix" the build error from server-only by removing the import, which puts you back where you started.

Keep it from coming back

The prefix is one rename away, and the next "make it work in the browser" request can bring it back. Two habits help.

Keep an allowlist of public names. A short check in CI that fails on any NEXT_PUBLIC_ name not on the list turns a silent leak into a failed build:

ALLOWED='^NEXT_PUBLIC_(SUPABASE_URL|SUPABASE_ANON_KEY|STRIPE_PUBLISHABLE_KEY|SITE_URL)$'
grep -rhoE 'NEXT_PUBLIC_[A-Z0-9_]+' --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' . \
  | sort -u | grep -vE "$ALLOWED" && { echo "Unexpected public variable"; exit 1; } || true

Adjust the list to the names your app really needs. Adding a new public variable then becomes a decision someone makes, not a side effect.

Check the deployed site, not just the repository. CI sees your code; it does not see a variable set by hand in the dashboard or an old preview deployment. This is what ScanYourSite for Cursor-built apps does: the free check downloads the HTML and scripts your live page serves and looks for secret keys from Stripe, OpenAI, Anthropic, AWS, GitHub, Supabase and others, reporting each one redacted with a link to where you revoke it. Public keys like the Supabase publishable key are shown as information, not alarms. The free plan checks one site weekly; on Pro, a new deploy is checked within minutes of going live, and after you verify the domain it also looks for exposed .env and .git files.

Whichever way you do it, run the live-site search after every release that touches environment variables, not just on launch day.

Check your own site in a minute

Free, no sign-up. You get a prioritised list of what’s wrong and a prompt you can paste back into Lovable, Bolt, Cursor or Claude Code.

Free, no sign-up. The basic check makes only the requests an ordinary browser would.

Cursor