Skip to content
Back to field notes
SecretsJune 6, 20266 min read

NEXT_PUBLIC_ and the accidental secret: what Next.js exposes at build time

In Next.js, any env var prefixed NEXT_PUBLIC_ is inlined into the JavaScript at build time and is readable by anyone who loads your site. This note shows how to check your bundle for a leaked key before you share the URL.

Next.jsSecretsEnvironment variables

A NEXT_PUBLIC_ prefix puts the value in your public JavaScript

The risk is specific: in Next.js, any environment variable whose name starts with NEXT_PUBLIC_ is inlined into the JavaScript that ships to the browser at next build. Whatever string that variable held is now sitting in a static file that anyone who loads your site can read. If you prefixed a Stripe secret key, an OpenAI key, or a database URL with NEXT_PUBLIC_, that secret is in the bundle, not on the server.

This is by design, not a bug. Next.js needs a way to tell apart values that are safe for the browser (a public API base URL, a publishable analytics token, a feature flag) from values that must stay on the server. The NEXT_PUBLIC_ prefix is that switch. Variables with the prefix are baked into the client build. Variables without it are only available on the server.

  • Observable: a NEXT_PUBLIC_ value is present in the built client JavaScript and is readable by any visitor, crawler, or script that loads the page.
  • Only possible: whether that exposed string is still a working credential is a separate question. A leaked key may be active, revoked, or scoped to nothing. Exposure is certain; exploitability is not.
  • This is a secrets-management gap (a credential reached a public surface), distinct from an authorization gap. The value leaks regardless of who is logged in.

Why AI-built Next.js apps hit this

The usual path is a wrong-prefix fix. A developer, or an AI tool like v0 or Cursor, writes a client component that reads process.env.STRIPE_SECRET_KEY or process.env.OPENAI_API_KEY. In a client component that value comes back undefined, because non-prefixed env vars do not exist in the browser. The error looks like a naming problem. The fastest way to make the undefined go away is to rename the variable to NEXT_PUBLIC_STRIPE_SECRET_KEY and set it in the environment. The code now works. The secret is now in the bundle.

That fix is tempting precisely because it makes the app run on the first try. The undefined disappears, the API call succeeds in development, and nothing in the build output says "you just published a secret." AI coding tools reproduce this pattern because it is a common answer on the public web to "env var is undefined in my React component," and because the generated client component is calling the third-party API directly from the browser instead of from a server route.

  • The non-prefixed var was working as intended: it was hidden from the browser, which is why it read as undefined there.
  • Adding the prefix did not fix the access problem. It moved a server secret onto the public surface to silence the error.
  • Fast deploy means the public URL exists before anyone greps the built bundle for the key.

Server-only vs public: the prefix rule and the App Router nuance

The rule is one line: a NEXT_PUBLIC_ variable is public; an unprefixed variable is server-only. Keep that distinction and most of this risk goes away.

Unprefixed env vars are available wherever your code runs on the server. In the App Router that means Server Components, Route Handlers, and Server Actions can read process.env.STRIPE_SECRET_KEY directly, and that value never leaves the server. In the Pages Router the same holds for getServerSideProps, getStaticProps, and API routes. None of these send the raw env value to the browser unless you deliberately pass it into client-facing props or a client component.

The App Router nuance is worth stating plainly, because it changes how the mistake happens. A Server Component can read a server-only secret with no prefix and no ceremony, render with it, and send only the result to the browser. The leak only happens when a Client Component (a file marked use client) needs the value and a developer reaches for the NEXT_PUBLIC_ prefix to get it there. The correct move in that case is not to expose the secret but to do the secret-using work on the server and pass down only what the client actually needs.

  • Public on purpose: a publishable Stripe key (the pk_ value), a public API base URL, a PostHog or analytics token meant for the browser. These can carry NEXT_PUBLIC_.
  • Server-only, never prefixed: a Stripe secret key (sk_live_ or sk_test_), an OpenAI or Anthropic key, a database connection string, a webhook signing secret, any admin token.
  • A Client Component cannot read a server-only var. That is the feature working, not a bug to prefix your way around.

Check your built bundle for the key

You can confirm this on your own app without attacking anything. The question is simple: is the secret string in the JavaScript that ships to the browser?

Build the app the way you deploy it (next build), then search the output. Grep the built client assets for the secret prefix and the variable name. For example, search the contents of the .next/static directory for sk_live_, sk_test_, and for NEXT_PUBLIC_. Known key shapes help: a Stripe secret key starts with sk_, an OpenAI key with sk-, and an exposed secret is often visible right next to its NEXT_PUBLIC_ variable name. You can also do this against the deployed site by opening DevTools, going to the Network tab, loading the page, and reading the served .js files, or by viewing page source and following the script URLs.

  • Suspicious result: a string that looks like a real secret (sk_live_ followed by a long token, a database URL with a password in it) appears anywhere in the served JavaScript. That secret is public.
  • Suspicious result: a variable named NEXT_PUBLIC_ something paired with a value that should never be public, such as NEXT_PUBLIC_STRIPE_SECRET_KEY or NEXT_PUBLIC_OPENAI_API_KEY. The name itself is the tell.
  • Safer result: the only NEXT_PUBLIC_ values you find are ones you intended to be public (a publishable key, a base URL, a feature flag), and no sk_live_ / sk- / connection-string-shaped value appears in the client assets.
  • Watch the Network tab too. If the browser makes a request directly to api.openai.com or api.stripe.com carrying a secret in an Authorization header, the call is being made client-side with an exposed key.

Keep secrets unprefixed and call third-party APIs server-side

The fix has two parts: stop shipping the secret, and rotate the one you already shipped.

Stop shipping it. Remove the NEXT_PUBLIC_ prefix from any variable that holds a real secret, and stop reading it from a Client Component. If a client interaction needs to hit Stripe, OpenAI, or your database, route that through a Route Handler (an app/api route) or a Server Action. The browser calls your own endpoint; your server reads the unprefixed secret and calls the third party. The key stays on the server and is never inlined into the bundle.

Rotate what leaked. Renaming the variable does not un-publish a key that already went out in a deployed build. Anyone could have already read it, and old deployments may still be cached. Treat any secret that was ever prefixed with NEXT_PUBLIC_ and deployed as compromised: rotate it at the provider (issue a new key, revoke the old one), then set the new value as an unprefixed, server-only env var and redeploy. Confirm the new build no longer carries the secret using the bundle check above.

  • Move the secret behind a Route Handler or Server Action; have the client call your endpoint, not the third party directly.
  • Keep the prefix only on values that are genuinely safe for the browser, like a publishable key or a public base URL.
  • After rotating, rebuild and re-grep the .next/static output to confirm the old key is gone and the new one is not present.

This fix removes the secret from the client bundle. It does not, on its own, add authentication or rate limiting to the new server endpoint you created. A Route Handler that proxies an AI or payments call still needs its own auth check and abuse controls, or you have moved the cost exposure rather than closed it.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check fetches your public JavaScript bundle and scans it for high-entropy strings and known key patterns, including the Stripe secret-key prefix sk_live_ and NEXT_PUBLIC_ variable names that carry secret-shaped values. If a secret made it into the served client assets, that is the surface the scan reads, and it flags the exposure with severity and fix direction. It checks what an outside visitor can actually pull from your site.

What it does not do is confirm whether the exposed key still works. Whether a flagged credential is live, revoked, or scoped to nothing is for you to verify, and any exposed secret should be rotated regardless. The scan also does not review your server code, so it cannot tell you whether the Route Handler you moved the secret behind is itself authenticated.

Next step: run next build, grep the .next/static output for sk_live_, sk-, and NEXT_PUBLIC_, and rotate anything that should not be there. Then run a focused Launch Check against the public URL before sharing it, so an exposed key shows up in your scan and not in someone else's.

A clean Launch Check on the bundle does not prove no secret ever leaked. The scan reads the JavaScript served right now; a key exposed in an earlier deployment, or held in a private env file the scan never sees, is outside its view. Treat a flagged key as a launch blocker and rotate it; treat a clean result as one signal, not a guarantee.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.