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.
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.
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.
1sk_live_... // Stripe secret2service_role ... // Supabase admin3sk-proj-... // OpenAI4DATABASE_URL=... // DB connection1pk_live_... // Stripe publishable2anon key // Supabase (with RLS on!)3publishable map/analytics IDs03The 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.
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:
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
- 01Rotate it nowGenerate a new key in the provider dashboard and revoke the old one. Assume the leaked one is compromised — it is.
- 02Purge it from git historyRemoving it in a new commit isn't enough; it lives in history. Use git filter-repo or BFG, then force-push.
- 03Move it server-sideRe-add the new key without the NEXT_PUBLIC prefix and route calls through your own endpoint.
- 04Check the billLeaked 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
- ✓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
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 →