CRITICAL Next.js · Vite · Create React App · any bundler

Secret keys shipped to the browser

What you'd see: You have an environment variable named something like NEXT_PUBLIC_OPENAI_API_KEY or VITE_STRIPE_SECRET_KEY.

§ 01 — What's actually happening

Bundlers inline any variable with a public prefix straight into the JavaScript they ship. It is not hidden, obfuscated or protected — it is a string in a file your visitors download. Anyone can find it with Ctrl+F. The prefix exists precisely to mark values as public; using it on a secret is the whole bug.

Why an AI tool writes it this way

Calling an AI or payment API from the browser is the shortest path from idea to working demo, and the framework helpfully tells you to add the prefix when the variable comes back undefined. The app starts working, so the change looks correct.

What it costs you

Bots scrape public sites for exactly these strings. An exposed LLM key gets drained within hours — we have seen five-figure bills. An exposed Stripe secret key or Supabase <code>service_role</code> key is worse: it is full account access, not just spend.

§ 02 — Before & after

Your API key is in the page source

Illustrative code, written for this page — never a client's project.

AI-generated

  • Key compiled into the JavaScript bundle
  • Visible to anyone who opens DevTools
  • No spend limit, no rate limit, no revocation plan
// src/lib/ai.js — runs in the browser const openai = new OpenAI({ apiKey: import.meta.env.VITE_OPENAI_API_KEY, // shipped to every visitor dangerouslyAllowBrowser: true // the name is a warning }); export const ask = (prompt) => openai.chat.completions.create({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] });

Human-reviewed

  • Key stays server-side and never reaches the browser
  • Your own endpoint sits in front of it, so you can authenticate and rate-limit
  • You control what the model is asked and what it costs
// app/api/ask/route.js — runs on the server only const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // no public prefix export async function POST(req) { const user = await requireUser(req); // must be signed in if (await overQuota(user.id)) { return Response.json({ error: 'Daily limit reached' }, { status: 429 }); } const { prompt } = await req.json(); const out = await openai.chat.completions.create({ model: 'gpt-4o', messages: [{ role: 'user', content: String(prompt).slice(0, 2000) }] }); await recordUsage(user.id, out.usage); return Response.json({ text: out.choices[0].message.content }); } // The browser now calls fetch('/api/ask') and never sees the key.

§ 03 — Check your own

How to tell in two minutes

You don't need us to run these. If any of them come back the wrong way, you have this problem.

  1. Open your live site, view source, and search the JavaScript for sk-, sk_live, service_role and Bearer.
  2. Grep your repo for NEXT_PUBLIC_ and VITE_ and read every match. Anything that is not genuinely public — a Supabase anon key, a Stripe publishable key, an analytics ID — does not belong there.
  3. Check your git history too: git log -p -- .env. Removing a key from the current code does nothing if it is still in an earlier commit.
  4. If you find one, rotate it first, then fix the code. A leaked key stays leaked until it is revoked.

Found it in your project? Fixing this one properly usually takes an engineer under an hour. Finding the other nine takes longer — which is what the free health check is for.

Get my free health check

Find out where you actually stand.

Free. No obligation. Reviewed by experienced engineers.

Get my free health check