Skip to content
KeyDrift
Scan for free
All posts

What NEXT_PUBLIC_ actually does

NEXT_PUBLIC_ is not a security feature. It is a build instruction that writes the literal value into the JavaScript you serve, and the consequences follow from that one fact.

KeyDrift5 min read
nextjsbundles

There is no process.env in a browser

process is a Node.js global. It does not exist on a user's machine, in their browser, in the JavaScript your CDN hands them. There is no environment to read.

This creates a problem for frontend frameworks, because people plainly do need configuration in client code — an API base URL, a project reference, a publishable key. Something has to bridge the gap between a value that exists at build time on your machine or your CI runner, and code that runs later on somebody else's.

Every frontend build tool solves this the same way: it substitutes. During the build, the bundler finds the places where you referenced an environment variable, and replaces the expression with the literal value as a string. Not a lookup. A find-and-replace, performed once, whose result is written into a file.

The prefix is how you opt in. NEXT_PUBLIC_ in Next.js, VITE_ in Vite, EXPO_PUBLIC_ in Expo. Different words, identical meaning: inline this value into the client bundle.

What the bundler does with the prefix

Before the build

Your source references an identifier. There is no credential anywhere in this file, and there is no credential in your repository, because .env is git-ignored and correctly so.

export const client = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

After the build

The bundler has resolved both expressions and written the values in. Minification then renames the local variables and strips the whitespace, which makes the output hard to read but does not make it private — the strings survive untouched, because a minifier cannot rename a string literal.

const s=o("https://example-project.supabase.co","eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.EXAMPLE_PAYLOAD.EXAMPLE_SIG")

That file is served from a public URL to anyone who requests it. No authentication, no referrer check, no rate limit worth the name. It is as public as your homepage, because it is part of your homepage.

Why the value cannot change without a rebuild

This is the part that surprises people who think of these as environment variables in the traditional sense, and it is the clearest evidence for what the prefix really is.

Change a prefixed variable in your hosting dashboard and restart the application. Nothing happens. The old value is still being served, because it is not being read from anywhere at request time — it was baked into a file weeks ago and that file has not changed. You have to rebuild and redeploy for a new value to reach a browser.

That single behaviour tells you everything about the mechanism. A runtime variable can be changed by restarting a process. A build-time variable is a constant in a compiled artifact. Prefixed variables are the second kind, which is why rotating a key means shipping a build, and why a rotation that skipped the rebuild has not actually taken effect anywhere the browser can see.

NEXT_PUBLIC_ is not a security feature. It is a build instruction.

What NEXT_PUBLIC_ actually does

It tells the Next.js build to replace every reference to that variable with its literal value in code destined for the browser, making the value permanently visible to every visitor.

That is the whole behaviour. It grants no protection, applies no scoping, and performs no check on what the value is. It is documented, intended and necessary — client code genuinely needs some configuration, and this is a reasonable way to provide it.

The problem is never the mechanism. The problem is which variable ends up behind the prefix, and the reason the wrong ones end up there is that the prefix is also the only way to make a value exist in client code. When something is undefined in the browser and adding six characters makes it defined, those six characters get added — under deadline, by a hurried person or by an assistant optimising for a working feature. The prefix is simultaneously the publication switch and the fix for a null-reference bug, and nothing in the tooling distinguishes those two intentions.

Some values belong there. A Supabase anon key, a Stripe publishable key, a Sentry DSN, a Mapbox public token — these are designed to be public, and prefixing them is correct. A service-role key, a sk_live_ key, a model-provider key or a database URL never belongs there under any circumstances. Both categories are configured identically, look identical in a diff, and are distinguished only by knowing what the value is.

Verifying which you have takes one command against your own deployed site:

curl -s https://example-app.test/_next/static/chunks/*.js | grep -oE '(eyJ[A-Za-z0-9_-]{20}|sk_live_[A-Za-z0-9]{8}|sk-proj-[A-Za-z0-9]{8}|AKIA[0-9A-Z]{8})'

Every hit is something you are shipping. Most of them will be fine. You should be able to say which.

Common questions

Is it safe to put my Supabase anon key in NEXT_PUBLIC_?

Yes — that is exactly what it is for. The anon key identifies the request as anonymous and is constrained by your row-level security policies. What makes it dangerous is not exposure but the absence of policies behind it: an anon key against a table with RLS disabled returns every row. The key is not the control; the policy is.

If I remove the variable and redeploy, is the old value gone?

The new bundle will not contain it, and that is worth doing. It does not undo the exposure. Anyone who loaded the previous build already has the value, caches and archives may continue serving the old chunk for some time, and automated scanners collect these continuously. Rotate at the provider first, then redeploy, then verify the new artifact.

Does this apply to server components?

Server components read real environment variables at runtime and do not need the prefix, which is why moving a call into one is a genuine fix. The trap is the boundary: a value read safely on the server and then passed as a prop to a client component is serialised into the payload sent to the browser. It never touched the prefix and it is public anyway.

Will my CI secret scanner catch this?

No, and it is not failing. Repository scanners read commits and working trees. The credential never appears in either — the source contains an identifier, and the value is substituted in during the build, on a runner, after the scan has already passed. Checking the built output is a separate step that most pipelines do not perform.

Run a free scan

KeyDrift reads the JavaScript your app actually serves and finds the Supabase, Stripe, OpenAI and AWS keys that should never have left your server. Run a free scan.

KeyDrift is a Veristria product. More about KeyDrift.