Peakstack
Contents
Part III. What actually breaks12 min read

API keys, env vars, and what NEXT_PUBLIC_ really means

The most common serious mistake in vibe coding, by volume, is a private key shipped inside the JavaScript your visitors download. It is also the easiest one to check for, which makes leaving it there indefensible.

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.

.env.localVulnerable
# 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?

ValueSafe in the browser?Why
NEXT_PUBLIC_SUPABASE_ANON_KEYYes, by designIt 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_KEYYes, by designDespite 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_)YesCan only start a payment. Cannot read or move money.
OPENAI_API_KEY (sk-)NeverSpends your money. No scope limits. No rate limit but yours.
SUPABASE_SERVICE_ROLE_KEYNeverBypasses 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_)NeverFull 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.

components/Chat.tsx ('use client')Vulnerable
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.

app/api/chat/route.ts (server, never sent to the browser)Fixed
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.

  1. Open your deployed site in a private window and open DevTools (F12).
  2. Go to the Sources tab (Chrome) or Debugger (Firefox).
  3. Search across all loaded files, with Cmd/Ctrl + Shift + F, for each of these:
Search your bundle for 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, somehow

Any 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.

Terminal
# 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.local

If 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.

When you’re ready to ship

Get an invite to PeakStack.

A bundle scan is exactly the kind of check that is trivial to run and easy to forget, every single deploy. It only takes one build where someone added a prefix to make an error go away. PeakStack drives your live app in a real browser, checks what your landing page promises against what the app actually does, and runs the security pass this chapter describes: exposed keys, open database rules, client-side admin gates. You get a letter grade, ranked fixes, and an honest list of what we couldn’t check.

Private beta. Join the waitlist for an invite.