Skip to content

Environment variables on Vercel, without leaking them into the browser

Next.js inlines env reads at build time, so one import can ship a server key to every visitor. Here is the boundary that prevents it and the checks that prove it held.

Next.js on Vercel4 min readships at docs/solutions/nextjs-vercel/env-vars-on-vercel-without-leaking-them.md

Tags: vercel · nextjs · environment-variables · secrets · security

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.env read 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_URL must match the deployment. It is the metadata base for canonical URLs and OG images. Leave it as http://localhost:3000 in 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:

  1. Rotate at the provider. Deleting the line does not un-publish the value.
  2. Update every Vercel scope with the new value.
  3. Redeploy, so the old bundle stops being served.
  4. Fix the code so the read is server-side.
  5. Check the provider's audit log for use between exposure and rotation.

Removal is not rotation. That sentence is the whole lesson.