A developer adds analytics. The SDK needs a key, the key is in .env.local, the
component is a client component, so:
"use client";
export function Tracker() {
const client = new Analytics(process.env.ANALYTICS_API_KEY);
// ...
}
It works locally. It works in preview. It works in production. Six weeks later
somebody opens devtools, searches the JavaScript bundle for ANALYTICS, and
finds the key sitting in plain text: served to every visitor since the day it
shipped, cached by every CDN in between.
Nothing errored. Nothing warned. That is what makes this the most common secret leak in a Next.js codebase.
Why it happens
Next.js does not read process.env in the browser: there is no environment
there. What it does instead is textually replace process.env.FOO with the
value at build time, in any module that ends up in a client bundle. The
replacement is not conditional on the variable being marked public. The
NEXT_PUBLIC_ prefix is a convention for humans, not a lock.
So the rule is simpler and harsher than most people assume:
If a module reaches a client bundle, every
process.envread inside it is published.
And "reaches a client bundle" is transitive. A Server Component that imports a
utility file is fine. A client component that imports the same utility file
publishes every env read in it. You can be leaking a key from a file that has no
"use client" anywhere in it.
The wrong way, in three flavours
// 1. The obvious one
"use client";
const key = process.env.STRIPE_SECRET_KEY;
// 2. The transitive one: src/lib/config.ts has no "use client",
// but a client component imports it, so it is bundled
export const config = { stripeKey: process.env.STRIPE_SECRET_KEY };
// 3. The "I renamed it so the warning went away" one
const key = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;
The third is the worst, because it looks like a fix. Renaming a secret to
NEXT_PUBLIC_ does not make it public-safe; it makes it public.
The right way: one server-only boundary
Read secrets in a Server Component, a Route Handler or a Server Action, and pass the derived result to the client, never the credential.
// src/lib/env.ts: the only place required variables are declared
export const REQUIRED_ENV = [
"NEXT_PUBLIC_APP_URL",
"STRIPE_SECRET_KEY",
] as const;
export function env(key: RequiredEnvKey): string {
const value = process.env[key];
if (value === undefined || value.trim() === "") {
throw new Error(`Missing required environment variable ${key}. See docs/onboard.md`);
}
return value;
}
// src/app/api/checkout/route.ts: server side, key never leaves the function
import Stripe from "stripe";
import { env } from "@/lib/env";
export const runtime = "nodejs";
export async function POST(request: Request) {
const stripe = new Stripe(env("STRIPE_SECRET_KEY"));
const session = await stripe.checkout.sessions.create({ /* ... */ });
return Response.json({ url: session.url }); // a URL, not a key
}
// the client gets a function to call, not a credential
"use client";
export function CheckoutButton() {
async function start() {
const response = await fetch("/api/checkout", { method: "POST" });
const { url } = await response.json();
window.location.href = url;
}
return <button onClick={start}>Upgrade</button>;
}
For values that genuinely are public (a publishable key, an analytics host, the
app URL) use the NEXT_PUBLIC_ prefix and write the read out literally:
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; // works
const publishable = process.env[keyName]; // does not
The build-time replacement is textual, so a computed lookup is undefined in the
browser. This surprises people every single time.
Setting them on Vercel
Vercel scopes each variable to Production, Preview and Development independently. A variable set only in Development fails the Production build, which reads as a compile error, not a config error, and sends people hunting through their code.
bunx vercel env ls
bunx vercel env add STRIPE_SECRET_KEY production
env add prompts for the value on stdin. Never pass a secret as a command-line
argument: it lands in shell history, in your terminal scrollback, and in any
agent transcript watching the session.
Two more that cost people an afternoon each:
NEXT_PUBLIC_*values are baked in at build time. Changing one in the dashboard does nothing until the next deploy. If a public value looks stale, this is why.NEXT_PUBLIC_APP_URLmust match the deployment. It is the metadata base for canonical URLs and OG images. Leave it ashttp://localhost:3000in production and every link preview breaks silently.
Prove it, three ways
One: every new variable lands in three places in the same change,
.env.example with a placeholder, src/lib/env.ts, and docs/onboard.md. If
it is in only two, the next clone fails.
Two: check the built bundle. This is the only check that cannot lie:
bun run build
grep -ril "sk_live" .next/static/ || echo "clean"
Do it for each secret's distinctive prefix. A hit means rotate immediately: the value is in every CDN cache and every browser that loaded the page.
Three: let the guards do it live. This repo ships env-leak-detector
(PreToolUse Bash) and env-leak-detector-write (PostToolUse Edit), which
between them block a credential in a shell command, a dump of .env, a secret
literal written into source, and a non-public process.env read inside a file
containing "use client". They catch the leak in the turn it is created rather
than six weeks later.
If a key has already shipped
In order, and do not reverse them:
- Rotate at the provider. Deleting the line does not un-publish the value.
- Update every Vercel scope with the new value.
- Redeploy, so the old bundle stops being served.
- Fix the code so the read is server-side.
- Check the provider's audit log for use between exposure and rotation.
Removal is not rotation. That sentence is the whole lesson.