NEXT_PUBLIC_ leaked my API key — how it happens and how to catch it
You added one prefix to make the build stop complaining, and your OpenAI key is now sitting in plain text inside the JavaScript that every visitor downloads. This is the most common way secrets leak out of a Next.js app, and it almost never looks like a mistake while you're making it.
The prefix is NEXT_PUBLIC_. The name is the whole story: anything you prefix with it is, by design, public. The problem is that nothing stops you from putting a private value behind a public name.
What NEXT_PUBLIC_ actually does
Next.js doesn't look up environment variables in the browser at runtime. Instead, at build time it scans every reference to a NEXT_PUBLIC_* variable in code that can reach the client and textually replaces it with the literal value. process.env.NEXT_PUBLIC_OPENAI_API_KEY becomes the string "sk-proj-…" baked directly into a .js chunk. (Non-prefixed process.env lookups in client code are simply replaced with undefined — there's nothing to read.)
That chunk ships to every browser. Anyone can open DevTools, search the bundle for sk-, and copy your key. Bots do this continuously against deployed sites and public repos — finding an exposed key is automated and fast.
Variables without the prefix are server-only. They're available in API routes, Server Components, Server Actions, and getServerSideProps, but they are never inlined into client code. That distinction — prefix vs. no prefix — is the entire security boundary.
Is NEXT_PUBLIC_ safe? Sometimes — and that's the trap
Plenty of values are supposed to be public, which is exactly why developers get comfortable with the prefix and then misuse it. These are genuinely safe in the bundle:
| Variable | Safe to expose? | Why |
|---|---|---|
NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes | Constrained by Row Level Security |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Yes | "Publishable" by definition; can't move money |
NEXT_PUBLIC_API_URL | Yes | A URL, not a credential |
NEXT_PUBLIC_OPENAI_API_KEY | No | Full account access, billed to you |
NEXT_PUBLIC_STRIPE_SECRET_KEY | No | Charges, refunds, payouts |
NEXT_PUBLIC_DATABASE_URL | No | Connection string with DB credentials |
The pattern: anon and publishable keys are designed to be handed to the browser. They're identifiers gated by another layer — RLS policies, Stripe's server-side checks, scoped permissions. A secret key, service role key, API key, or access token has no such gate. It is the gate. Put it in the browser and there's nothing left protecting the account behind it.
How it actually happens
Nobody decides to publish their API key. It leaks through ordinary, reasonable-looking steps:
- The integration "needs" it client-side. You're calling OpenAI from a React component.
process.env.OPENAI_API_KEYisundefinedin the browser, so you rename itNEXT_PUBLIC_OPENAI_API_KEYand the error disappears. So does your security. dangerouslyAllowBrowser: true. The OpenAI SDK refuses to run in the browser unless you set this flag. The flag is named to scream at you, but under deadline pressure people flip it and move on.- Copy-paste from a tutorial. Quick-start guides prefix everything with
NEXT_PUBLIC_so their demo works without a backend. Copy that into production and you've shipped the demo's bad habit.
Here's the canonical version — a client component talking to OpenAI directly:
"use client";
import OpenAI from "openai";
// Anything named NEXT_PUBLIC_* is inlined into the client bundle.
export const openai = new OpenAI({
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY!,
dangerouslyAllowBrowser: true,
});
This compiles. It works in development. It works in production — for the user and for the attacker reading your bundle. Here's what GuardLayer reports on exactly that file. This is live engine output, not a mockup:
- Criticallib/openai.ts:6
Secret exposed through NEXT_PUBLIC_
Drop the NEXT_PUBLIC_ prefix and read the value only on the server. Publishable/anon keys are fine to expose; secret keys, tokens, and passwords are not — rotate any that have shipped.
Why it's worse than it looks
- The key has no blast radius limit. An exposed OpenAI key lets anyone run inference billed to your account until you notice the invoice. A secret Stripe key can issue refunds and read every charge. A database URL is the whole database.
- It's permanent once shipped. The moment that bundle is deployed, assume the key is captured. Rotating later is mandatory, not optional — the old key is burned.
- It's silent. No error, no warning, no failing test. Everything works perfectly, which is precisely why it survives code review.
The fix
The rule is simple: secrets never touch the client, so they never get a NEXT_PUBLIC_ prefix. Call the third-party API from the server and have your component talk to your server instead.
1. Read the key server-side only — in an API route or Server Action:
// app/api/chat/route.ts — runs on the server, key stays server-side
import "server-only";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // no NEXT_PUBLIC_ prefix
});
export async function POST(req: Request) {
const { prompt } = await req.json();
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
});
return Response.json({ text: completion.choices[0].message.content });
}
2. The client calls your route, never OpenAI:
"use client";
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
const { text } = await res.json();
The browser now only knows about /api/chat. The key lives in one place, on the server, where you can also add auth, rate limiting, and input validation. The server-only package turns any accidental client import of that module into a build-time error — a cheap guardrail for anything that touches a secret.
3. If it already shipped, rotate first, then refactor. Regenerate the key in the provider's dashboard immediately — the exposed one must be considered burned. Then move it server-side. If it was ever committed, also purge it from git history (git filter-repo or BFG); deleting the line in a later commit does not remove it.
30-second self-check
# 1. Any secret-shaped value behind the public prefix?
grep -rnE "NEXT_PUBLIC_[A-Z0-9_]*(SECRET|API_KEY|TOKEN|PASSWORD|SERVICE_ROLE)" .
# 2. The OpenAI "I know this is dangerous" flag anywhere?
grep -rn "dangerouslyAllowBrowser" .
# 3. Grep your built bundle for key shapes
grep -rE "sk-[A-Za-z0-9_-]{16,}" .next/ 2>/dev/null
A hit on any of these means you have a key in the browser. (NEXT_PUBLIC_*_ANON_KEY and *_PUBLISHABLE_KEY are expected and safe — those are the exceptions.)
FAQ
Is NEXT_PUBLIC_ safe to use at all?
Yes — for values that are meant to be public: anon keys, publishable keys, public URLs, analytics IDs, reCAPTCHA site keys. The prefix is only dangerous when the value behind it is an actual secret.
My anon/publishable key is in the bundle. Is that a problem?
No. Anon keys (gated by RLS) and publishable keys (gated server-side by the provider) are designed to be public. That's why GuardLayer flags NEXT_PUBLIC_OPENAI_API_KEY but not NEXT_PUBLIC_SUPABASE_ANON_KEY.
Can't I just hide the key with an env var at runtime? There is no client runtime env in Next.js. Anything the browser uses is decided at build time and baked into the bundle. If the browser can read it, so can a visitor.
I only use the key in a Server Component / API route. Am I safe?
Yes, as long as the variable has no NEXT_PUBLIC_ prefix and the module is never imported by a "use client" file. Add import "server-only" to enforce it.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.