The only line that matters
There is exactly one boundary you need to hold in your head, and every rule in this chapter is a consequence of it:
Code that runs in the browser is code you have given away. Every variable in it, every string, every key. The visitor has all of it, whether or not you meant them to.
It is not obfuscated. It is not compiled beyond recognition. “Minified” means the whitespace is gone, not that the secrets are. Anyone can open DevTools, hit Ctrl-F, and type sk-. Bots do it at scale, automatically, on every domain they can find, and they are not looking for you. They are looking for keys.
What NEXT_PUBLIC_ actually does
This prefix is responsible for more leaked keys than any other single thing in the ecosystem, and the reason is a genuine failure of naming. It reads like a namespace. It is not. It is an instruction.
At build time, Next.js scans your code for process.env.NEXT_PUBLIC_ANYTHING and physically substitutes the value into the JavaScript bundle it ships to browsers. The variable does not get read at runtime by the client. Its literal value is baked into a public file. The same is true of VITE_ in Vite, REACT_APP_ in Create React App, and PUBLIC_ in Astro and SvelteKit.
# This is now public. Not "somewhat exposed". Public.
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-abc123...
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...The moment you prefix it, you have published it. This is not a warning about a risk. It is a description of what the build does.
Why does this happen so often? Because the app did not work without it. The developer, or the model, wrote a component that calls OpenAI, got undefined for the key, added the prefix, and the error went away. It genuinely fixed the error. It fixed it by publishing the key.
What counts as a secret
Not everything with the word “key” in it is dangerous, and the confusion here causes real paralysis. The test is simple: can someone who has this value do something you would not let a stranger do?
| Value | Safe in the browser? | Why |
|---|---|---|
NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes, by design | It identifies your project, and it is meant to be public. Its power is bounded entirely by your RLS policies. If RLS is off, this key opens your database, but the key is not the bug then. The missing policy is. |
NEXT_PUBLIC_FIREBASE_API_KEY | Yes, by design | Despite the name, this is an identifier, not a credential. Google says so explicitly. Your Firestore rules are what protect the data. |
STRIPE_PUBLISHABLE_KEY (pk_) | Yes | Can only start a payment. Cannot read or move money. |
OPENAI_API_KEY (sk-) | Never | Spends your money. No scope limits. No rate limit but yours. |
SUPABASE_SERVICE_ROLE_KEY | Never | Bypasses RLS entirely. It is the master key to the whole database, and it is the single worst thing on this page to leak. |
STRIPE_SECRET_KEY (sk_live_) | Never | Full account access. Refunds, payouts, customer data. |
The fix: call the API from your own server
The key must never travel to the browser, so the code that uses it must not run there. Instead of the browser calling OpenAI directly, the browser calls you, and you call OpenAI. The key stays on your server, where the visitor cannot see it and, just as importantly, where you can count, cap, and authorise every call.
const openai = new OpenAI({
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
});
const res = await openai.chat.completions.create({ ... });Every visitor now has your key. They do not need to attack anything. They just need to open the file you sent them.
import OpenAI from "openai";
import { getSession } from "@/lib/auth";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req: Request) {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const { ok } = await rateLimit(session.user.id);
if (!ok) return new Response("Slow down", { status: 429 });
const { messages } = await req.json();
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages,
});
return Response.json(res.choices[0].message);
}No NEXT_PUBLIC_ prefix, so the value stays on the server. Note the two lines that have nothing to do with secrecy: the auth check and the rate limit. This is where they belong, and they only become possible once the call runs on your side.
The client then calls /api/chat. It never sees the key, cannot call OpenAI as you, and cannot exceed the limit you set. You have also, as a free side effect, closed failure #6 from the atlas: the uncapped credit card.
Check your own app in ninety seconds
Do this now, on your live site. It takes less time than reading about it.
- Open your deployed site in a private window and open DevTools (F12).
- Go to the Sources tab (Chrome) or Debugger (Firefox).
- Search across all loaded files, with Cmd/Ctrl + Shift + F, for each of these:
sk- OpenAI / Anthropic style keys
sk_live_ Stripe secret key (live!)
service_role Supabase master key. Bypasses every policy.
SECRET catches most misnamed variables
password catches hardcoded database URLs
BEGIN PRIVATE a private key, in your bundle, somehowAny hit on the first five is an emergency. Rotate the key first, then fix the code, in that order, because the leaked one is already scraped.
And check git, while you are here
The other half of this problem is a .env file that got committed. Deleting the file in a later commit does not help. Git keeps history, and if the repo is public, the key is in it forever.
# Is .env ignored, as it should be?
cat .gitignore | grep env
# Was it ever committed, at any point in history?
git log --all --full-history -- .env .env.localIf that second command returns anything at all, the key in it is compromised. Rotate it. Rewriting git history is possible but fiddly, and it is not the priority. The key being live is the priority.