Your NEXT_PUBLIC_ Env Var Is Shipping Your Secret Key to the Browser
Everyone worries about committing a .env file to git. The leak I actually find in AI-generated apps is quieter, already deployed, and visible to anyone who opens DevTools.
When people think about leaked secrets, they picture a .env file accidentally committed to a public GitHub repo. That happens — but it's loud, scanners catch it, and GitHub will even email you about it.
The leak I find far more often in AI-generated apps is the opposite of loud. The .env file is correctly git-ignored. Nothing shows up in the repo. And yet the secret is sitting in plain text inside the JavaScript every visitor downloads — because of a seven-character prefix: NEXT_PUBLIC_.
What the prefix actually does
In Next.js, any environment variable prefixed with NEXT_PUBLIC_ is inlined into the client bundle at build time. That's the entire point of the prefix: it's how you make a value available to code running in the browser. A publishable Stripe key or a Supabase anon key is meant to be public, so the prefix is correct for those.
The problem is what happens when an AI assistant — or a tired developer — reaches for the prefix because it's the one that “always works” in a component. Here's the kind of .env.local I see:
# .env.local NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... # ok — anon key is public NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51Hxxxxxx # NOT ok — this is on the browser now NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-xxxxxxxx # NOT ok — anyone can drain your quota NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@... # NOT ok — full DB creds, public
The first two lines are fine. The last three are a disaster. A sk_live_ Stripe key, an OpenAI key, and a full Postgres connection string — each one prefixed, each one now baked into a file at /_next/static/chunks/ that anyone can download and grep.
Why AI tools generate this
Ask an assistant to “call OpenAI from my app” and it writes a component that reads process.env.SOMETHING. When that component runs in the browser and the value comes back undefined, the “fix” the model has seen a thousand times in its training data is: add the NEXT_PUBLIC_ prefix. The error goes away. The code works. The demo runs.
// lib/stripe.ts — generated by an AI assistant import Stripe from "stripe"; // "It needs the key, and NEXT_PUBLIC_ makes the env var work in the // component, so..." — the AI reaches for the prefix that always resolves. export const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
Nothing in the local dev experience tells you anything is wrong. The build succeeds. The feature works. The key is exposed. This is the exact same failure mode as the Stripe webhook bug: the AI made the happy path work, and the security property was never part of the happy path.
How bad is it, really
Depends on the key, but the realistic range runs from “expensive” to “company-ending”:
- OpenAI / Anthropic key — bots scrape public bundles for these constantly. Expect your quota drained and a surprise bill within days.
- Stripe secret key — full API access to create charges, issue refunds, and read every customer's details. Not “steal a credit card” — “become your Stripe account.”
- Database connection string — direct read/write to your production data, no app in the way.
- Service-role / admin keys (Supabase service_role, Firebase admin) — these bypass every row-level security rule you wrote. The whole point of the key is that it has no limits.
And rotating the key isn't enough on its own — once it's shipped in a bundle, you have to assume it's already been harvested. Rotate first, then audit for what was done with it.
How to find it in your own app
Two checks, 60 seconds. First, grep your env files for any prefixed secret-shaped name:
grep -rEi 'NEXT_PUBLIC_[A-Z_]*(SECRET|PRIVATE|TOKEN|PASSWORD|API_KEY|DATABASE)' .env*
Second — and this is the one that doesn't lie — search the actual built output. If a value is in here, it's public, full stop:
npm run build grep -rEi 'sk_live_|sk-proj-|postgres://|service_role' .next/static
On a deployed site you can do the same thing from the browser: open DevTools → Sources → search the bundle for sk_live_. If your own secret comes up, so does an attacker's.
The fix
The rule is simple: a secret never gets the prefix. If code needs a real secret, that code has to run on the server — an API route, a Server Action, a server component, or a route handler — and read the un-prefixed variable.
# .env.local NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... # public by design — fine to expose # No NEXT_PUBLIC_ prefix → server-only, never sent to the browser. STRIPE_SECRET_KEY=sk_live_51Hxxxxxx OPENAI_API_KEY=sk-proj-xxxxxxxx DATABASE_URL=postgres://user:pass@host/db
Then keep the secret-using code server-side, and add the server-only package as a guardrail — it turns “oops, a client component imported this” into a build error instead of a silent leak:
// lib/stripe.ts — server-only module import "server-only"; // build error if this is ever imported by a client component import Stripe from "stripe"; // Read the un-prefixed var. This code only runs on the server, // so the key never reaches the bundle the browser downloads. export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
If the browser genuinely needs to trigger something that uses the secret (charge a card, call OpenAI), the browser calls your API route, and your API route uses the key. The secret stays on your server; the client only ever sees the result.
The mental model that prevents it
Sort every value into one of two buckets before you name the variable:
- Safe to print on a billboard — publishable keys, anon keys, public URLs. These can take
NEXT_PUBLIC_. - Would ruin your week if a stranger had it — secret keys, service-role keys, DB credentials, signing secrets. These never take the prefix, and the code that uses them never runs in the browser.
When an AI assistant adds NEXT_PUBLIC_ to make an error go away, that's the moment to stop and ask which bucket the value is in. Nine times out of ten the real fix is “move this call to the server,” not “expose the key.”
Other secret-handling mistakes in the same family
The prefixed-secret leak is the most common, but it travels with a few cousins worth grepping for at the same time:
- Hardcoded keys — the AI skips env vars entirely and writes
const key = "sk_live_..."straight in the source. - Committed
.env— the file isn't in.gitignore, so the secret is in git history even after you delete it. - Secrets in client-side error logs — a key interpolated into a thrown error that gets sent to the browser or a third-party logger.
- Keys in URLs — passed as a query string, where they end up in server logs, browser history, and referrer headers.
Worth a 30-second check
I build XploitScan, a security scanner with rules written specifically for the mistakes AI coding tools make — and exposed secrets, including the NEXT_PUBLIC_-on-a-secret pattern, hardcoded keys, and committed .env files, are among the first things it looks for. It runs locally or in your browser, nothing is uploaded, and it's free to try.
But the more durable takeaway is the mental model: the NEXT_PUBLIC_ prefix is a publishing decision, not a convenience flag. Every time you add it, you're saying “I'm fine with the whole world reading this.” Most of the time, for a secret, you're not.
Check your code for exposed secrets
Paste your code or drop your project. Free. No signup.
Scan Your Code — Free