All posts
VibeZero Team8 min read

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.

ShareXLinkedIn