Next.js environment variables: find and stop secrets leaking to the client (2026)
Three ways a Next.js App Router build leaks server secrets even without NEXT_PUBLIC_: props to Client Components, process.env reads in prerendered Client Components, and frozen public values. Tested on Next.js 15.1.9, with a build scanner for CI.
Table of Contents
- How do Next.js secrets leak to the browser without NEXT_PUBLIC_?
- Short answer
- Threat model
- Test: three attack paths on a real build
- Path 1: secret passed as a prop
- Path 2: process.env read inside a Client Component
- Path 3: frozen NEXT_PUBLIC_ values
- Summary of test results
- Controls
- 1. Read secrets only in a server-only module
- 2. Pass DTOs, not config objects
- 3. Enable taint as a backstop
- 4. Decide what is genuinely public
- Verification: scan the build for real secret values
- Sources
- Internal links
How do Next.js secrets leak to the browser without NEXT_PUBLIC_?
Short answer
The NEXT_PUBLIC_ prefix is not the only way a secret reaches the browser. According to the Next.js environment variables guide, only NEXT_PUBLIC_ values are inlined into client JavaScript, so many teams assume every other variable is safe. In our App Router test build, a server-only key still appeared in the client-facing output in two ways that never used the prefix:
- A Server Component passed an object containing the key as a prop to a Client Component. The key was serialized into the prerendered HTML and the
.rscpayload. - A Client Component read
process.env.STRIPE_SECRET_KEYdirectly. The client bundle did not contain the key, but the prerendered HTML did, because Client Components also render on the server.
The fix is structural: keep process.env reads in a server-only module, pass minimal DTOs across the client boundary, enable taint as a backstop, and scan the build output for real secret values in CI.
Threat model
The asset is any credential that grants server-side power: payment API keys, database URLs, signing secrets, and third-party admin tokens. The attacker needs nothing special: anyone who can open the page can read its HTML, its JavaScript chunks, and its RSC payload. A leak in a statically prerendered page is especially costly because the CDN serves the same file to every visitor until the next deploy.
Next.js draws the boundary at two places, and each one fails differently:
- Client bundle (
.next/static): Next.js inlines onlyNEXT_PUBLIC_*values at build time and leaves other references asprocess.env.X. It fails when a value is prefixed by mistake: that value is public forever in that build. - Server-to-client render output (HTML and
.rsc): Next.js serializes every prop passed to a Client Component and every string a Client Component renders on the server. It fails when a server-only value reaches a Client Component prop or render output: the value ships as data.
The Next.js data security guide states the second point directly: Client Components "run on the server during prerendering, but must follow the same security assumptions as code running in the browser."
Test: three attack paths on a real build
We built a minimal App Router fixture with Next.js 15.1.9 and React 19.0.0 (this repository's pinned versions). The .env.production file contained two canary values:
NEXT_PUBLIC_API_BASE=https://api.example.com/canary-public-7f3a
STRIPE_SECRET_KEY=sk_test_CANARY_SERVER_9b21
Path 1: secret passed as a prop
// app/leak-prop/page.jsx (Server Component)
import ShowConfig from '../ShowConfig';
export default function Page() {
const config = {
apiBase: process.env.NEXT_PUBLIC_API_BASE,
stripeKey: process.env.STRIPE_SECRET_KEY,
};
return <ShowConfig config={config} />;
}
ShowConfig is a Client Component that renders only config.apiBase. It never displays the key, yet the build output contained it:
$ grep -o 'stripeKey[^,}]*' .next/server/app/leak-prop.rsc
stripeKey":"sk_test_CANARY_SERVER_9b21"
React serializes the whole prop object, not just the fields the component uses.
Path 2: process.env read inside a Client Component
// app/env-client/Client.jsx
'use client';
export default function Client() {
return (
<pre>
{String(process.env.NEXT_PUBLIC_API_BASE)}|{String(process.env.STRIPE_SECRET_KEY)}
</pre>
);
}
The client chunk behaved as documented: the public value was inlined and the secret reference stayed as an unresolved expression.
$ grep -o '.\{20\}canary-public-7f3a.\{70\}' .next/static/chunks/app/env-client/page-*.js
[String("https://api.example.com/canary-public-7f3a"),"|",String(process.env.STRIPE_SECRET_KEY)]
The prerendered HTML, however, was generated on the server, where process.env.STRIPE_SECRET_KEY exists:
$ grep -o '<pre>.*</pre>' .next/server/app/env-client.html
<pre>https://api.example.com/canary-public-7f3a<!-- -->|<!-- -->sk_test_CANARY_SERVER_9b21</pre>
Checking only .next/static for secrets would have reported this build as clean.
Path 3: frozen NEXT_PUBLIC_ values
This path is not a leak of a server secret, but it causes bad workarounds. The Next.js documentation notes that after a build, NEXT_PUBLIC_ values are frozen. If one Docker image is promoted from staging to production, the browser still sees the staging value. Teams that hit this sometimes move the value to a non-prefixed variable and pass it down from a Server Component, which is fine for a public URL but becomes Path 1 the moment someone adds a secret to the same config object.
Summary of test results
NEXT_PUBLIC_API_BASEread in a Client Component: inlined in client JS and present in HTML, as expected.STRIPE_SECRET_KEYpassed as a prop: absent from client JS, but present in the HTML and.rscpayload.STRIPE_SECRET_KEYread in a Client Component: absent from client JS, but present in the HTML.STRIPE_SECRET_KEYread through aserver-onlymodule imported by a Client Component: the build failed.STRIPE_SECRET_KEYtainted withexperimental_taintUniqueValueand passed to a Client Component: the build failed.
Controls
1. Read secrets only in a server-only module
Put every process.env read for credentials behind one module that cannot be imported from the client:
// lib/secrets.js
import 'server-only';
export function getStripeKey() {
return process.env.STRIPE_SECRET_KEY;
}
When a Client Component imported this module, the build stopped:
./lib/secrets.js
Error: You're importing a component that needs "server-only". That only works in a Server Component ...
Import trace for requested module:
./lib/secrets.js
./app/so/Client.jsx
This blocks Path 2. It does not block Path 1, because the Server Component is allowed to import the module and then hand the value to a client prop.
2. Pass DTOs, not config objects
The data security guide recommends returning minimal Data Transfer Objects from the Data Access Layer. Apply the same rule to configuration: build the prop from named public fields instead of spreading a config object.
export default function Page() {
return <ShowConfig config={{ apiBase: process.env.NEXT_PUBLIC_API_BASE }} />;
}
During review, treat any Client Component prop typed as Config, Env, Settings, or a full database row as a finding.
3. Enable taint as a backstop
The experimental.taint option enables React's experimental_taintUniqueValue. Register the secret once, in the server-only module that reads it:
// next.config.js
module.exports = { experimental: { taint: true } };
// lib/secrets.js
import 'server-only';
import { experimental_taintUniqueValue } from 'react';
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (stripeKey) {
experimental_taintUniqueValue('Do not pass STRIPE_SECRET_KEY to the client.', process, stripeKey);
}
export function getStripeKey() {
return stripeKey;
}
In our fixture, a page that passed the tainted value to a Client Component failed prerendering with the custom message:
Error occurred prerendering page "/taint".
Error: Do not pass STRIPE_SECRET_KEY to the client.
Export encountered an error on /taint/page: /taint, exiting the build.
Taint is experimental, and the Next.js guide calls it "an additional layer of protection." React's documentation also warns that a value derived from the tainted string, such as an uppercased or sliced copy, is not tainted. Keep DTOs as the primary control.
4. Decide what is genuinely public
Use this rule when someone proposes a new NEXT_PUBLIC_ variable:
- Analytics ID, public API base URL, feature flag default: acceptable. Assume anyone can read it.
- Publishable keys designed for browsers (for example, Stripe
pk_): acceptable. The vendor designs them for client use; restrict them in the vendor dashboard. - Secret API keys, database URLs, webhook signing secrets, JWT secrets: never. Rotate immediately if one was ever built with the prefix.
- Values that differ per environment for the same image: avoid. Read them on the server during dynamic rendering instead.
If a secret has shipped in any public build, renaming the variable does not help. Old HTML and chunks may remain in CDN caches, preview deployments, and browser caches. Rotate the credential first, then fix the code.
Verification: scan the build for real secret values
Searching source code for NEXT_PUBLIC_ finds Path 3 mistakes only. To catch Paths 1 and 2, search the build output for the actual secret values. This script reads the named variables from the environment of the CI job and fails if any of them appear in client chunks or prerendered output:
// scripts/scan-build-for-secrets.mjs
// Usage: node scripts/scan-build-for-secrets.mjs STRIPE_SECRET_KEY DATABASE_URL
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const names = process.argv.slice(2);
const secrets = names
.map((name) => [name, process.env[name]])
.filter(([, value]) => value && value.length >= 8);
// Files that reach the browser: client chunks plus prerendered HTML and RSC payloads.
const roots = ['.next/static', '.next/server/app'];
const exts = ['.js', '.html', '.rsc', '.json', '.body'];
function* walk(dir) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) yield* walk(path);
else if (exts.some((ext) => path.endsWith(ext))) yield path;
}
}
let leaks = 0;
for (const root of roots) {
for (const file of walk(root)) {
// Server bundles under .next/server/app/**/page.js may legitimately reference secrets.
if (root === '.next/server/app' && file.endsWith('.js')) continue;
const text = readFileSync(file, 'utf8');
for (const [name, value] of secrets) {
if (text.includes(value)) {
console.log(`LEAK ${name} -> ${file}`);
leaks++;
}
}
}
}
console.log(
leaks
? `${leaks} leak(s) found`
: `OK: ${secrets.length} secret(s) not found in client-facing output`
);
process.exit(leaks ? 1 : 0);
Output against the vulnerable fixture:
LEAK STRIPE_SECRET_KEY -> .next/server/app/env-client.html
LEAK STRIPE_SECRET_KEY -> .next/server/app/leak-prop.html
LEAK STRIPE_SECRET_KEY -> .next/server/app/leak-prop.rsc
3 leak(s) found
Output after applying the DTO and server-only fixes:
OK: 1 secret(s) not found in client-facing output
Two limits matter. First, the script prints only variable names and file paths, never values, so it is safe to run in CI logs; still, run it only in jobs that already have access to those secrets. Second, it covers prerendered routes only. For dynamic routes, request the page in a staging deployment, save both the HTML and the RSC response (send the RSC: 1 request header), and search those files the same way.
In GitHub Actions, run it right after next build in the same job, so the secrets come from the same environment the build used. If you harden that workflow, follow the least-privilege rules in our GitHub Actions hardening checklist.
Sources
- How to use environment variables in Next.js — Next.js Docs
- How to think about data security in Next.js — Next.js Docs
experimental.taint— Next.js config referenceexperimental_taintUniqueValue— React Docs- Test environment: Next.js 15.1.9 and React 19.0.0 (App Router, webpack build, static prerendering). The outputs above were captured from that fixture; file names and chunk hashes differ in other builds and versions.
Internal links
Editor’s picks
- 1Permit2 explained (Web3): why approvals changed and how to use it safely (checklist)
- 2Read wallet signing screens (Web3): a 30-second checklist to avoid permission traps
- 3Spec-to-implementation prompt template (AI development): how to stop the model from guessing
- 4Revoke token approvals on EVM: how to audit allowances safely (checklist)
- 5Clarifying questions checklist (AI development): what to ask before you let an LLM build