NEXT_PUBLIC_ leaks: how secrets get into a JavaScript bundle
Any variable with a public prefix is shipped to every visitor. How NEXT_PUBLIC_, VITE_, PUBLIC_ and NUXT_PUBLIC_ work, how to check your bundle, and what to do.
Crunu teamPublished 7 min read
On this page
A variable whose name starts with NEXT_PUBLIC_ is copied into the JavaScript that Next.js sends to the browser, at build time, for every visitor to read. If a secret key was given that prefix, it is public: anyone can open the browser's developer tools or download the bundle and find it. The same is true of VITE_ in Vite, PUBLIC_ in Astro, public runtime config in Nuxt, and public variables in SvelteKit. The fix is to reissue the key at its provider, move it to a server-only variable, and rebuild. Rebuilding alone leaves the old key working and already copied.
This post explains what each framework does with public variables, how to search your own build output, why a public key sometimes is fine, and what to do when a real secret got through. For the per-framework load order, see the stack pages such as environment variables in Next.js.
Why the prefix exists
Frameworks that render both on the server and in the browser need a way to tell the two apart. Code on the server can read process.env at run time. Code in the browser has no process environment, so if a component needs a value, the framework has to put it into the files the browser downloads.
The prefix is how you opt a variable in. Next.js describes it plainly: to make a value 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." The prefix is the instruction to do that (Next.js: environment variables).
So the prefix is not a label for "configuration" as opposed to "secret". It means "ship this to every visitor".
What each framework does
Next.js. Variables without the prefix "are only available in the Node.js environment". Values with NEXT_PUBLIC_ are inlined during next build and "frozen with the value evaluated at build time", so a Docker image built once and promoted through environments carries the build's values everywhere. Dynamic lookups such as process.env[varName] are not inlined.
Vite. "Variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling." The documentation adds that "VITE_* variables should not contain sensitive information such as API keys" and that "the values of these variables are bundled into your source code at build time" (Vite: env variables and modes).
Astro. All variables are available in server code, but "only environment variables prefixed with PUBLIC_ are available in client-side code." With the typed astro:env schema, public client variables "end up in both your final client and server bundles", and secret client variables "are not supported because there is no safe way to send this data to the client" (Astro: environment variables).
Nuxt. Keys under runtimeConfig.public are "also exposed to the client-side", and Nuxt "adds it to each page payload". An environment variable named NUXT_PUBLIC_… overrides the matching public key at run time (Nuxt: runtime config). The value isn't baked into the bundle, but it is sent with every page, which is no more private.
SvelteKit. SvelteKit 3 deprecated the $env/* modules "in favour of explicit environment variables". Variables are now declared in src/env.ts; "by default, all variables are considered private", and the private module "cannot be imported into code that runs in the browser". A variable marked public: true can be imported from $app/env/public, and one marked static: true "will be inlined into your application code" (SvelteKit: environment variables). Projects still on SvelteKit 2 use the older $env/static/public and $env/dynamic/public modules, which serve the same purpose.
| Framework | How a variable becomes public | When the value is fixed |
|---|---|---|
| Next.js | NEXT_PUBLIC_ prefix | Build time |
| Vite | VITE_ prefix | Build time |
| Astro | PUBLIC_ prefix, or context: "client" in astro:env | Build time |
| Nuxt | runtimeConfig.public, overridden by NUXT_PUBLIC_… | Run time, in the page payload |
| SvelteKit 3 | public: true in src/env.ts | Run time, or build time with static: true |
How keys end up there
Nobody sets out to publish a secret key. The common paths are mundane.
- A client component needs it. A form calls a third-party API from the browser, the request fails because the key is missing, and adding the prefix makes the error go away.
- Copying a variable list. A
.envfrom one project is copied into another, prefixes and all. - A provider's quickstart. Some providers, Stripe among them, issue a publishable key meant for the browser and a secret key meant for servers. Putting the wrong one under the public name is an easy mistake.
- A "temporary" debug value that never got removed.
Check your build output
The bundle is just files. After a production build, search them for anything that looks like a secret. Start with the prefixes of keys you know you use:
npm run build
grep -rEo "sk_live_[A-Za-z0-9]+|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}" .next/staticFor Vite and Astro, the client output is usually under dist/; for SvelteKit, under .svelte-kit/output/client or your adapter's output directory. Then search for the names of your server-only variables too, in case a value was referenced through a different public name.
Two more checks catch what grep misses:
- List what is public on purpose.
grep -rhoE "NEXT_PUBLIC_[A-Z0-9_]+" src app | sort -uprints every public name the code refers to. Read the list and ask, for each one, whether you would be comfortable seeing its value on a billboard. - Look from the outside. Open the deployed site, open developer tools, and search the loaded scripts and the page source for a key prefix. That is exactly what anyone else can do.
The env linter flags secret-looking values under public names in a .env file before they get built.
When a public key is fine
Some keys are designed to be public. Payment providers issue publishable keys for the browser; analytics and error-reporting tools use identifiers that only allow sending data; map services issue browser keys you restrict by domain. Those belong under a public name, as long as you have applied the restrictions the provider offers: allowed domains, allowed APIs, rate limits.
The line is what the key allows. If it can only identify your site to a service, it is configuration. If it can read data, spend money or act as your account, it is a secret and must stay on the server. The glossary entry on public environment variables has more examples.
When a secret got into the bundle
Treat it like any leaked credential. Every visitor who loaded the page since the deploy may have it, and so may caches, archived copies and anyone who scraped the site.
- Reissue the key at the provider. Create a new one, deploy it to a server-only variable, and revoke the old one. Steps by provider are in the key rotation guides, for example Stripe.
- Move the call to the server. A route handler, server action or API endpoint that holds the key and does the work, so the browser never needs it.
- Rename the variable without the public prefix, so the framework refuses to ship it.
- Rebuild and redeploy, then repeat the bundle search.
- Check the provider's logs for use of the old key between the first deploy and the revocation.
Rebuilding without reissuing is not a fix. The old build was public; the old key still works.
Keep server values on the server
The structural answer is to keep secrets in variables the framework will never inline, and to deliver them to the server process when it starts rather than to the build.
With Crunu, the server runs your app through the agent, for example crunu run --env web-app/production -- node server.js. The variables exist only in that process's memory, so there is no .env on the build machine to pick up by accident. In Next.js, a server-only variable read during static rendering is read at build time, so values that must come from the running server belong in code that renders dynamically, as the Next.js documentation shows. Anything the framework inlines into the bundle is, by definition, not a secret.
Public values are different: they must be present at build time, so they go to the build step. Keeping them in a separate environment from the server secrets, with its own access, makes the boundary visible. A build job that receives only the environment of public values can't inline a key it never had.
Sources
- How to use environment variables in Next.js, Next.js documentation, checked October 30, 2026.
- Env variables and modes, Vite documentation, checked October 30, 2026.
- Using environment variables, Astro documentation, checked October 30, 2026.
- Runtime config, Nuxt documentation, checked October 30, 2026.
- Environment variables, SvelteKit documentation, checked October 30, 2026.