← Alle Neuigkeiten
data-exposure5 Min. Lesezeit

Why a NEXT_PUBLIC_ prefix on the wrong variable ships your API key to every browser

Von Seaworthy · 3. August 2026

Lines of code displayed on a screen

Photo by Florian Olivo on Unsplash

A client component throws process.env.STRIPE_SECRET_KEY is undefined in the browser console. The variable is set correctly in .env and works fine in every server-side API route. The fastest fix that makes the error disappear is renaming it to NEXT_PUBLIC_STRIPE_SECRET_KEY. The error goes away. The app works. And the secret key is now sitting in plaintext inside a JavaScript file that ships to every visitor's browser.

Next.js's environment variable handling is genuinely useful and genuinely easy to misuse in exactly this way. Any variable prefixed NEXT_PUBLIC_ gets statically inlined into the client bundle at build time, Webpack or Turbopack walks the code, finds every reference to process.env.NEXT_PUBLIC_X, and replaces it with the literal value before the JavaScript ever reaches a browser. There's no runtime check, no warning, no distinction at the point of use between "this is a publishable key meant to be public" and "this is a secret key that happens to share a naming pattern with one." The prefix is the entire security boundary, and it's opt-in by string-matching a variable name.

Why the prefix gets added without anyone deciding it should be

Nobody sits down and decides a server secret belongs in the browser. It happens because the failure mode looks identical to a completely different, harmless problem.

Most Next.js projects genuinely do need some public runtime values, a publishable Stripe key, an analytics site ID, a public API base URL. Those are supposed to use NEXT_PUBLIC_, and when a developer or an AI coding assistant sees process.env.SOMETHING is undefined in a client component, the correct-looking fix is right there in the docs and in every Stack Overflow answer: add the prefix. It's a one-line change that resolves the error immediately, with no indication of what that variable's value actually is or where else in the codebase it's used.

This is worse in AI-assisted workflows specifically, because the two variable families often sit next to each other with names that only differ by one word:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...

An assistant pattern-matching on "how do I fix undefined env var in client component" has no way to know that SECRET in the name means something different from PUBLISHABLE. Both look like config. Both resolve the same class of error with the same fix. Only one of them is safe to expose.

What "exposed" actually means here

It's not hidden, obfuscated, or hard to find. Minification doesn't touch string literals, a minified bundle still contains the exact key value as plain text, just with shorter variable names around it. Anyone can:

  1. Open browser devtools, go to the Sources or Network tab, and view any loaded JS chunk.
  2. curl the page and grep the served JavaScript for a recognizable prefix like sk_live_ or AIza or AKIA.
  3. Use view-source: on the page and search for the variable name they'd expect, since Next.js often keeps the original name as a property key even after minification.

None of this requires any skill beyond knowing where to look, and scanners that grep public JS bundles for exposed API key patterns are a standing, automated category of internet background noise, this isn't a targeted-attacker-only risk.

The fix: keep the server/client boundary explicit

The rule is simple to state and easy to violate under deadline pressure: a value only gets the NEXT_PUBLIC_ prefix if you would be comfortable with it appearing, unencrypted, in every visitor's browser dev tools. If the answer is no, it stays server-only and the client talks to a route handler or Server Action instead of touching the value directly.

// Bad: secret key referenced from a client component
"use client";
export function Checkout() {
  const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY); // shipped to the browser
  // ...
}
// Fixed: secret stays server-only, client only ever sees the publishable key
// app/api/create-payment-intent/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // no NEXT_PUBLIC_ prefix, never bundled

export async function POST(request) {
  const { amount } = await request.json();
  const intent = await stripe.paymentIntents.create({ amount, currency: "usd" });
  return Response.json({ clientSecret: intent.client_secret });
}
// Client component only ever references the actual publishable key
"use client";
const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);

The client never sees the secret key at all. It calls a server route, the server route holds the real credential, and the only thing that crosses the network back to the browser is a short-lived, purpose-scoped token, exactly the pattern Stripe, and most providers with a secret/publishable key split, were designed around.

If a value must be configurable per-environment but should never reach the browser, the checklist is short: no NEXT_PUBLIC_ prefix, read it only in Server Components, Route Handlers, or Server Actions, and if a client component genuinely needs the result of using that secret, fetch it through an API route rather than passing the credential itself down as a prop.

Seaworthy's data exposure checks scan for exactly this combination: an environment variable name carrying a client-exposed prefix (NEXT_PUBLIC_ and equivalent patterns in other frameworks) paired with a value that matches known secret formats, API key prefixes, JWT structure, connection-string syntax, and flags it before that pairing ever reaches a production bundle.

Dieser Artikel wurde von KI generiert und fasst öffentlich verfügbare Quellen zusammen.

Von den Machern von Seaworthy

Seaworthy scannt dein Repository nach den Problemen, die in Artikeln wie diesem behandelt werden.

Sicherheitslücken, offengelegte Geheimnisse und Fehlkonfigurationen — erkannt, bevor du bereitstellst. Kostenlos, kein Konto nötig.

$ npx seaworthycode