Client-side env prefixes, explained
NEXT_PUBLIC_ and VITE_ are not naming conventions. They are instructions to your bundler to publish the value. What that means and how to check your build.
- security
- vibe coding
- engineering
NEXT_PUBLIC_STRIPE_SECRET_KEY reads like a careful variable name. It has an
environment variable’s shape, it sits in a .env file, and .env files are
where secrets go. Everything about it signals that somebody thought about this.
It is a publish instruction. The prefix is the one thing in the name with any mechanical meaning, and what it means is: take this value and write it into the JavaScript that every visitor downloads.
This is the single most productive misunderstanding in AI-generated frontends, and it is productive precisely because the name looks like a precaution.
What the prefix actually does
Both major bundlers document this plainly, and both use the same word.
Next.js: to make an environment variable available in the browser, Next.js
“can ‘inline’ a value, at build time, into the js bundle that is delivered to
the client, replacing all references to process.env.[variable] with a
hard-coded value. To tell it to do this, you just have to prefix the variable
with NEXT_PUBLIC_.”
Vite: “Variables prefixed with VITE_ will be exposed in client-side source code
after Vite bundling,” and, in a security note, “VITE_* variables should not
contain sensitive information such as API keys. The values of these variables are
bundled into your source code at build time.”
Read those two together and the mechanism is exact. The variable does not exist
at runtime in the browser. There is no lookup, no process.env object shipped to
the client, nothing to intercept. The build performed a string substitution. By
the time your JavaScript is served, the value is a literal in the file, exactly
as if somebody had typed it there, which is
a hardcoded credential with three extra
steps and a reassuring name.
Next.js spells out the consequence that surprises people even when they know the security part: “After being built, your app will no longer respond to changes to these environment variables.” Rotate the key in your hosting dashboard and nothing happens until you rebuild, because the old value is baked into the artifact.
The three ways it happens
Mirroring a server variable to make an error go away. The most common by a
wide margin. A component calls an API, the call needs a key, the key is in
process.env.STRIPE_SECRET_KEY, and in the browser that is undefined. The
error message says the variable is missing. The fastest change that makes the
error stop is adding the prefix, because the value then arrives and the feature
works.
The reasoning is not stupid, it is just aimed at the wrong problem. The variable was not missing. It was correctly absent, because the code asking for it was running in the wrong place. Adding the prefix answers “how do I get this value into the browser” when the real question was “why is this call in the browser.”
Coding assistants make this failure more likely for a structural reason: you paste the error, and the error says a variable is undefined. The most direct repair for an undefined variable is to define it. Nothing in the error text carries the information that this particular variable must never be defined there.
Copying the whole .env into the client-visible half. Someone gets tired of
per-variable decisions and prefixes everything, or a generator writes a .env
where every entry has the same prefix because the template did. Our runtime
scanner’s remediation copy names this route directly when it finds a credential
in a bundle: anything in a frontend bundle is readable by every visitor,
including values a build tool inlined from a .env file.
Believing the dynamic lookup escape. Next.js notes that dynamic lookups are not inlined:
// Not inlined, because the key is a variable.
const name = "NEXT_PUBLIC_ANALYTICS_ID";
setupAnalyticsService(process.env[name]);
This is a documented fact about the substitution and it is not a security
control. In the browser there is no environment to read, so this does not hide
the value, it produces undefined. On the server it works, and on the server you
never needed the prefix. If you find yourself reaching for it, the value belongs
in a server route.
Which values genuinely belong there
The prefix exists because some values must reach the browser, and treating every prefixed variable as a bug is its own kind of wrong. The line is not about sensitivity in the abstract, it is about what the value authorizes.
Safe by design: a Supabase publishable or anon key, a Stripe publishable
key, a PostHog project key, a Sentry DSN, a Google Maps key restricted by HTTP
referrer, your own API’s base URL. These are identifiers. They tell a service
which project a request belongs to, and the actual authorization is done
somewhere else, by a policy or a domain restriction or a session.
Never: anything named secret, service, admin, private or password. A Supabase
service_role or sb_secret_ key,
which ignores every RLS policy you wrote.
A Stripe sk_live_ key. An OpenAI or Anthropic key, where the exposure is
metered in your money rather than your data. A JWT signing secret. A database
connection string.
The Supabase pair deserves a note because the two keys look identical under the
legacy format, both starting eyJ and differing only in a role claim inside
the payload. NEXT_PUBLIC_SUPABASE_ANON_KEY is correct and
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY is a catastrophe, and the two names
differ by one word in a file nobody reads carefully.
Moving the call, not the string
The fix is never a rename. It is relocating the code that needs the secret to a place the browser cannot execute. Every framework in this space has a one-file answer.
// app/api/quote/route.ts (Next.js Route Handler, server only)
export async function POST(request: Request) {
const { items } = await request.json();
// No prefix. This value never leaves the server process.
const res = await fetch("https://api.provider.com/v1/quote", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PROVIDER_SECRET_KEY}` },
body: JSON.stringify({ items }),
});
// Return only what the UI needs. Do not proxy the provider's whole response,
// which is how a secret ends up in a JSON payload instead of a bundle.
const { total, currency } = await res.json();
return Response.json({ total, currency });
}
For a Vite single-page app there is no server half, so the destination is a Supabase Edge Function, a Cloudflare Worker, or any small backend. The rule is identical: the key lives in that function’s environment, the browser calls the function, and the function authenticates the caller by their own session before doing the privileged part.
Two failure modes to avoid while relocating:
- A pass-through proxy is not a fix. A route handler that forwards any request the client sends, with your key attached, has published the key’s capability rather than the key. Constrain what the route will do.
- Server-rendered props are client code. A value read on the server and
placed in a component prop, a
window.__DATA__blob, or a JSON response has been shipped to the browser just as thoroughly as an inlined variable. The prefix is one route to publication and not the only one.
What a scan sees
Our free scanner reads the JavaScript your deployed site actually serves, which is the only vantage point from which this class is a fact rather than a hypothesis. Source code shows intent. The bundle shows what shipped.
It matches credentials with a recognizable shape (the Supabase key tiers, AWS,
Stripe, Clerk, GitHub, Google, private keys, model providers, connection
strings) and grades the Supabase tiers deliberately differently: a service_role
or sb_secret_ key is reported at critical, a publishable or anon key at info
with text saying this is where the key belongs. Telling them apart takes
base64-decoding the JWT payload and reading the role claim, because a pattern
matcher cannot distinguish them, and getting it backwards in either direction
would be a product failure. You can run that against your own site with the
Supabase key checker, or the wider sweep with the
exposed API key scanner.
There is one limit worth stating, since a scanner quietly missing something is worse than one that says what it cannot do. A credential without a recognizable shape is invisible from the outside. A 44-character HMAC signing secret in a bundle is indistinguishable from a build hash, so no bundle scanner will tell you the one in your JavaScript signs your tokens. That one you find by knowing where you put it, or by grepping your own build output as in the callout above.
Source maps make the outside view sharper, in both directions. Our runtime
engine reports source-maps-referenced when a bundle points at one, and
source-map-exposed at HIGH when that .map is actually public, because a
published source map rebuilds your original files, comments and variable names in
anyone’s developer tools. For this class that means an attacker does not just see
a suspicious literal, they see it assigned to NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY
with your original formatting intact.
The prefix is a decision, not a description
Environment variables feel safe because of a habit: they are the thing you were
told to use instead of hardcoding. So a value in a .env file inherits the
sense of having been handled properly, and the prefix rides along on that
feeling.
But the .env file is only a staging area, and the build decides what leaves it.
Two variables in the same file, adjacent lines, identical formatting: one stays
on the server and one is published to every visitor, and the only thing that
decided which was a substring of the name. That is why this shows up on
Bolt, whose database documentation has
recommended the VITE_ prefix for Supabase keys, on
Lovable, and on
v0 through NEXT_PUBLIC_, where Vercel now
scans at generation time and has reported blocking over 100,000 insecure
deployments as a result.
The check takes a minute and answers the question exactly: build the project, grep the output, and read what you are actually shipping. If a secret is in there, the next step is rotation before cleanup, because the value has been public for as long as the site has been deployed.