← Writing
Security

Your API keys are in your frontend. Here's how I find them in 4 minutes.

The most common finding in a launch teardown: a secret key shipped to every visitor. How I find it, what's actually safe, and the three-step fix.

Christos MalamasJun 20266 min read

The single most common thing I find in a launch teardown is a secret key sitting in the frontend. Not hidden. Not obfuscated. Just there, in plain text, shipped to every visitor. Here's exactly how I find it — and the three-step fix.

01Why this happens — the NEXT_PUBLIC trap

Frameworks split environment variables into two buckets: server-only, and ones explicitly exposed to the browser. In Next.js the browser bucket is anything prefixed NEXT_PUBLIC_. In Vite it'sVITE_. The prefix is a loaded gun: it tells the bundler “inline this value into the JavaScript everyone downloads.”

AI tools reach for the prefix because it makes the call work from a client component with no extra plumbing. It works — and it ships your key to the world.

Where a secret key ends up, by approach
Public
NEXT_PUBLIC_ var
Hidden
Server route
A NEXT_PUBLIC_ value is inlined into the client bundle — 100% visible. A server route keeps the key in the runtime the browser never touches.

02What's actually safe to expose

Not every key is a secret. The line is simple: publishablekeys are designed for the browser; secret keys never are.

Never in the browser
1sk_live_...        // Stripe secret2service_role ...   // Supabase admin3sk-proj-...        // OpenAI4DATABASE_URL=...   // DB connection
Safe in the browser
1pk_live_...        // Stripe publishable2anon key           // Supabase (with RLS on!)3publishable map/analytics IDs

03The fix — proxy through your own server

The pattern is always the same: the browser calls your endpoint, your server holds the secret and calls the provider. The key never leaves the server runtime.

app/api/ai/route.jsjs
1import OpenAI from 'openai'2// key read server-side — no NEXT_PUBLIC prefix3const ai = new OpenAI({ apiKey: process.env.OPENAI_KEY })4 5export async function POST(req) {6  const { prompt } = await req.json()7  // (auth + rate-limit checks go here)8  const out = await ai.chat.completions.create({9    model: 'gpt-4o-mini',10    messages: [{ role: 'user', content: prompt }],11  })12  return Response.json({ text: out.choices[0].message.content })13}

The client just calls that route — it never sees the key:

client componentjsx
1const res = await fetch('/api/ai', {2  method: 'POST',3  body: JSON.stringify({ prompt }),4})5const { text } = await res.json()

04If a key already leaked

  1. 01
    Rotate it now
    Generate a new key in the provider dashboard and revoke the old one. Assume the leaked one is compromised — it is.
  2. 02
    Purge it from git history
    Removing it in a new commit isn't enough; it lives in history. Use git filter-repo or BFG, then force-push.
  3. 03
    Move it server-side
    Re-add the new key without the NEXT_PUBLIC prefix and route calls through your own endpoint.
  4. 04
    Check the bill
    Leaked AI and cloud keys get drained by bots fast. Review usage and set a spend cap.

If the browser can read it, treat it as printed on a billboard. The only place a secret is secret is a server the public can't reach.

The one-line version
Keys, done right
  • Secret keys live in non-prefixed env vars, read only on the server
  • The browser calls your route; your route calls the provider
  • Publishable keys are fine in the client — secret keys never are
  • Supabase anon key is only safe with Row Level Security enabled
  • A leaked key is a rotate-and-purge job, not a delete-the-line job
Found this useful?

We'll find your biggest launch risk. Free. In 48 hours.

Send your app — get a 3–5 minute video from a senior founder-engineer showing the one thing most likely to break trust, and how to fix it.

Get a free teardown →