VITE_ And NEXT_PUBLIC_ Are Not Naming Conventions. They Are Publish Buttons.
Renaming a variable so it stops being undefined pastes its value into a public JavaScript file. What client env prefixes really do at build time, the dynamic lookup that inlines every prefixed variable in Vite, the define config that leaked a whole shell environment, and a CI check that catches it.
The Variable Worked Locally, So It Shipped
Picture a small internal tool. Someone wires in an LLM feature, writes import.meta.env.OPENAI_API_KEY, and gets undefined. They search for the error, find a Stack Overflow answer that says "Vite only exposes variables prefixed with VITE_", rename the variable, and everything works. The PR is one line in .env and one line in a component. It gets approved.
That rename did not fix a bug. It published a secret. The API key is now a string literal inside a JavaScript file that anyone can download, and the fact that it "worked" is exactly the problem.
I wanted to see this with my own eyes rather than repeat what the docs say, so I set up a throwaway Vite 8.3 project on Node 26 with a .env file full of fake credentials and built it a few different ways. Here is the first build. The source reads one prefixed variable and one unprefixed one:
console.log(import.meta.env.VITE_API_URL, import.meta.env.DATABASE_URL)
And here is the relevant slice of the production bundle:
console.log(`https://api.example.com`, void 0)
No lookup. No runtime config. The value is pasted into the file as a string, and the unprefixed variable became void 0. That is the whole mechanism, and once you see it written out like this, the prefix stops looking like a naming convention.
VITE_ and NEXT_PUBLIC_ are not naming conventions. They are publish buttons, and you press them in a .env file.
I spend most of my time these days on frontend infrastructure, and a lot of that work is about the boundary between build time and runtime. This boundary is the one people get wrong most confidently, because the tooling makes a dangerous action feel like a configuration detail.
It Is Find-And-Replace, Not An Environment
The word "environment variable" carries baggage from the server. On a Node server, process.env.DATABASE_URL is read when the code runs, from the process that is running it. Change the variable, restart, new value. The secret lives in memory on a machine you control.
A browser has no such thing. There is no process environment for your JavaScript to read. So bundlers fake it: at build time, they take expressions like import.meta.env.VITE_API_URL and replace them with the literal value from the build machine. By the time the file reaches a user, there is nothing "environment" about it. It is a constant.
The prefix is the allowlist for that replacement. Vite's own documentation is blunt about it: VITE_* variables should not contain sensitive information, because they end up in the client bundle. Next.js does the same thing with NEXT_PUBLIC_, and Create React App did it with REACT_APP_ for years before that.
- Build time, not runtime. Change the value on your host after deploying and nothing happens. The old value is baked into the files already on the CDN.
- Per build, not per environment. If you want the same artefact promoted from staging to production, prefixed variables fight you, because staging values are frozen inside it.
- Public, not "client-side". "Exposed to the client" sounds like it means your client. It means every client, including the one running
curlon your JS chunks.
I think "exposed" is the most misleading word in this whole area. It sounds like a scope. It is actually a disclosure.
Anything a prefixed variable holds, treat as if you had typed it into a <script> tag on your homepage. If that sentence makes you uncomfortable about a particular value, it does not belong behind the prefix.
One Dynamic Lookup Ships Every Prefixed Variable
The first build only inlined the variable I actually read. I checked: a third prefixed variable, VITE_UNUSED_TOKEN, did not appear anywhere in the output. So far so tidy, and it is tempting to conclude that only the values you reference get published.
Then I changed one line. Instead of reading a property directly, I read it through a variable, the kind of thing people write inside a small config helper:
const k = 'VITE_' + 'API_URL' console.log(import.meta.env[k])
console.log({BASE_URL:`/`,DEV:!1,MODE:`production`,PROD:!0,SSR:!1, VITE_API_URL:`https://api.example.com`, VITE_STRIPE_SECRET_KEY:`sk_live_FAKE_1234567890`, VITE_UNUSED_TOKEN:`unused_FAKE_abc`}.VITE_API_URL)
Vite cannot know which key you will ask for, so it does the only safe thing for correctness: it inlines the entire object. Every prefixed variable goes in, including the one nobody uses. Logging import.meta.env whole, or passing it into a function, produces the same object literal.
This matters because "we only reference the public ones" is a common defence when someone spots a sensitive-looking prefixed variable in a .env file. It is only true until one file does a dynamic lookup, and nothing in the build output will tell you that happened.
| Access pattern (Vite 8.3) | What lands in the bundle |
|---|---|
import.meta.env.VITE_X | Only the value of VITE_X |
import.meta.env.DATABASE_URL | void 0, nothing leaked |
import.meta.env[key] | Every VITE_ variable, used or not |
console.log(import.meta.env) | Every VITE_ variable, used or not |
Vite Blocks The Obvious Mistake And Warns On The Worse One
People hit the prefix wall and look for a way around it. The first idea is usually to set the prefix to nothing. Vite refuses outright. With envPrefix: '' the build fails:
error during build:
Error: envPrefix option contains value '', which could lead unexpected
exposure of sensitive information.
Good. The second idea is the one that usually comes from migrating a Create React App or webpack project, where code is full of process.env.SOMETHING. Someone adds a define so the old code keeps working:
import { defineConfig, loadEnv } from 'vite' export default defineConfig(({ mode }) => ({ define: { // "just make process.env work like before" 'process.env': loadEnv(mode, process.cwd(), ''), }, }))
This one is not blocked. Vite 8.3 printed a warning that I had passed "the entire process.env object to define, which can unintentionally expose all environment variables", and then built successfully with exit code 0. In a CI log with a few hundred lines of output, that warning is easy to scroll past.
What actually shipped was worse than I expected. loadEnv with an empty prefix does not just read your .env files. It also merges in the environment of the process running the build. When I scanned the bundle, it contained the fake DATABASE_URL and the fake Stripe key from .env, but also PATH, HOME, USER, SSH_AUTH_SOCK and dozens of other variables from my shell, including tokens set by other tools.
In CI, the build process environment is where your deploy tokens, registry credentials and cloud keys live. A define that serialises the environment can publish those too, and they were never in any .env file for a reviewer to notice.
One nuance, to be fair to the pattern: when the code only reads process.env.NODE_ENV directly, the object is not serialised and nothing leaks. It is the same whole-object behaviour from the previous section. The config is a loaded gun that fires the first time someone writes process.env[key]. If you need compatibility, define individual keys, like 'process.env.API_URL': JSON.stringify(env.VITE_API_URL), and never the object.
NEXT_PUBLIC_ Has The Same Contract And Different Edges
Next.js follows the same model with NEXT_PUBLIC_. Prefixed values are inlined into the JavaScript sent to the browser during next build, frozen at the value they had when the build ran. Variables without the prefix stay on the server: Server Components, Route Handlers, Server Actions and so on.
I ran the same kind of experiment on Next.js 16.3.5 with a Client Component, and the edges were not quite what I expected from older explanations of this, which say dynamic lookups are never inlined:
'use client' export default function Client() { const name = 'NEXT_PUBLIC_DYNAMIC' const env = process.env return <p> {process.env.NEXT_PUBLIC_DIRECT} {/* inlined */} {process.env[name]} {/* inlined: the key is a constant */} {env.NEXT_PUBLIC_DESTRUCT} {/* inlined: the alias is followed */} {process.env.SERVER_SECRET} {/* left as a runtime lookup */} </p> }
The bundler folded the constant key and followed the alias, so all three prefixed values landed in the chunk as string literals. The unprefixed secret stayed as e.default.env.SERVER_SECRET, which is undefined in the browser. When I made the key genuinely runtime (read from location.hash) and also rendered JSON.stringify(process.env), no values were inlined at all, and an unused NEXT_PUBLIC_ variable never appeared in any build.
So a truly dynamic lookup fails closed in Next (you get undefined), while in Vite it fails open (you get every prefixed variable). Same idea, opposite failure mode. If your team works in both, that asymmetry is the sort of thing that turns into a confusing bug in one repo and a leak in the other. And "is this lookup dynamic enough?" depends on your bundler's constant folding, which is exactly why I would not build anything on it.
The Next-specific trap I would watch for is different anyway. It is not the prefix. It is a Client Component importing a module that reads an unprefixed secret. The secret will not be inlined, so it shows up as undefined in the browser, and the "fix" someone reaches for is, again, renaming it to NEXT_PUBLIC_. Same one-line PR as section one, different framework.
The correct fix in both cases is structural, not a rename: keep the code that needs the secret on the server. That is also why I like the server-only package in Next apps. Importing it at the top of a module that touches credentials turns "this accidentally ended up in a client bundle" into a build error.
Not Every Key In The Bundle Is A Leak
I want to be careful here, because the opposite overreaction is also common. Someone runs a scanner, finds "API key" strings in a production bundle, and files a critical security bug. Plenty of keys are designed to be public. What separates them is whether the key grants authority by itself or only identifies your project while something else enforces the rules.
| Credential | Safe in the browser? | What actually protects you |
|---|---|---|
Stripe publishable key (pk_) | Yes | It can only create tokens and client-side objects |
Stripe secret key (sk_) | Never | Nothing. It is full API access |
Firebase web config apiKey | Yes | Security Rules and App Check, not the key |
| Supabase anon / publishable key | Yes | Row Level Security, which must be on |
Supabase service_role key | Never | Nothing. It bypasses Row Level Security |
| Google Maps browser key | Yes, if restricted | HTTP referrer and API restrictions on the key |
| OpenAI / Anthropic API key | Never | Nothing. It bills your account |
The LLM row deserves its own mention because it is the one I see asked about most right now. Both the official OpenAI and Anthropic JavaScript SDKs refuse to run in a browser unless you pass an option literally named dangerouslyAllowBrowser. That flag is the SDK authors telling you the same thing this article is: a key in the browser belongs to whoever opens DevTools.
A public key identifies your project. A secret key is your project. Only one of them belongs in a bundle.
So the useful question in review is not "is there a key in the bundle?". It is "if a stranger copied this value, what could they do without anything else?". If the answer is "nothing our rules do not already allow", it is fine behind a prefix. If the answer involves your bill, your database, or your users' data, it is not.
Deleting The Variable Does Not Un-Publish It
If you find a real secret in a deployed bundle, the instinct is to remove the prefix, redeploy, and move on. That closes the door for future visitors and does nothing about the copies that already exist.
- Hashed filenames stay around. Many static hosts and CDNs keep previous assets reachable so that users with an old tab open do not get broken chunks. The old file with the old key may still be one URL away.
- Caches do not care about your redeploy. Browser caches, proxy caches and service workers can all hold the old bundle.
- Crawlers and archives exist. Public sites get crawled. Assume a snapshot of that JS file is somewhere you cannot reach.
- Git history is its own leak. If the
.envfile was committed, the secret is in history regardless of what the bundle says now.
So the order is: rotate first, then clean up. Revoke the credential with the provider and issue a new one, check the provider's usage logs for anything you do not recognise, and only then worry about purging caches and rewriting history. A rotated key sitting in an old bundle is harmless. A live key in a "fixed" deployment is not.
This is the same shape of problem as shipping production source maps, which I wrote about in Your Production Source Maps Are Leaking Your Entire Codebase. The build output is public. Anything that goes into it is published, whether you meant it as documentation or not.
Make The Build Fail Instead Of Trusting Review
Code review did not catch the one-line PR in section one, and I do not think it is reasonable to expect it to. The diff looked like config. The guardrail that works is a check on the thing that actually gets published: the build output.
Here is a small script I tested against the leaky and clean builds from this article. It loads everything Vite can see, fails if any unprefixed value appears in dist, and separately fails if a prefixed variable looks like a known server credential format:
import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { loadEnv } from 'vite' const mode = process.argv[2] ?? 'production' const outDir = process.argv[3] ?? 'dist' // Every variable Vite can see, prefixed or not const env = loadEnv(mode, process.cwd(), '') // Values that must never reach the browser: anything without the client prefix const secrets = Object.entries(env) .filter(([key, value]) => !key.startsWith('VITE_') && value.length >= 8) // Prefixed values that still look like server credentials const suspicious = /^(sk_live_|sk_test_|sk-|ghp_|xox[bp]-|AKIA)/ const files = (dir) => readdirSync(dir).flatMap((name) => { const path = join(dir, name) return statSync(path).isDirectory() ? files(path) : [path] }) let failed = false for (const [key, value] of Object.entries(env)) { if (key.startsWith('VITE_') && suspicious.test(value)) { console.error(`${key} looks like a server credential but is exposed to the client`) failed = true } } for (const file of files(outDir)) { const text = readFileSync(file, 'utf8') for (const [key, value] of secrets) { if (text.includes(value)) { console.error(`${file}: contains the value of ${key}`) failed = true } } } process.exit(failed ? 1 : 0)
On the define build it failed loudly, listing DATABASE_URL and a long tail of shell variables. On the clean build it exited 0. Two honest limitations: it only catches secrets whose values the build machine can see, and a short value can occasionally produce a false positive. Run it in CI straight after the build step, where the real credentials are present, and it catches the cases that matter.
The architectural guardrail is the one that removes the problem instead of detecting it. If the browser needs to call something that requires a secret, it calls your server, and your server holds the secret:
The extra hop feels like overhead for a prototype. It is also where rate limiting, auth checks and usage caps go, which you will want the moment the prototype has real users. For generic secret scanning beyond your own .env, tools like gitleaks and TruffleHog can scan directories and git history too, and they are worth adding alongside the bundle check.
Review The Prefix Like A Deploy
If you keep one thing from this, keep the short version:
- Prefixed variables are string literals in public files. The prefix is a publish decision, not a scope.
- In Vite, one dynamic
import.meta.env[key]publishes every prefixed variable, used or not. define: { 'process.env': ... }only warns, and withloadEnv(mode, cwd, '')it can serialise the whole build machine environment.- In Next.js, a truly runtime lookup inlines nothing. Same model, opposite failure mode.
- Some keys are public by design. Ask what a stranger could do with the value alone.
- If a real secret shipped, rotate it first. Redeploying does not un-publish anything.
None of this is a flaw in Vite or Next. Build-time inlining is the right design for static assets, and both tools document it clearly. The problem is the word "environment", which makes a publish step sound like a lookup. When a PR adds a prefix to a variable, I now read it the way I would read a PR that adds a string to index.html, because that is what it is.
If renaming a variable made the error go away, check what the rename published.
import.meta.env access, the envPrefix: '' error, the define warning and what it leaked, and the scan script) were reproduced locally with Vite 8.3.0 on Node 26 using fake credentials. The Next.js behaviour was reproduced with Next.js 16.3.5 and its default production build; constant folding of env lookups can differ between bundler versions, so verify against your own build output. Provider key guidance comes from each provider's own docs; check them for your exact product before deciding a key is safe to expose.Related essays
- Security · Build Systems
Your Production Source Maps Are Leaking Your Entire Codebase to Anyone Who Knows Where to Look
Most frontend teams ship source maps to production without realising they're handing anyone a complete, readable copy of their application code. Here's what's actually inside them, how they get exploited, and what to do about it today.
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.
- React · Security
Your React Server Components Are Leaking User Data Through Client Boundaries. Here's How.
Why the server/client boundary in React Server Components is a security minefield that most teams are crossing blindly - and how serialisation turns your careful access control into a data exposure incident waiting to happen.