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
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
§ 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.
- Open your live site, view source, and search the JavaScript for
sk-,sk_live,service_roleandBearer. - Grep your repo for
NEXT_PUBLIC_andVITE_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. - 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. - 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 →§ 04 — Related
Other things we find
Stripe webhook accepts unverified events
Your webhook route reads the request body and acts on it directly.
Read the fix → CRITICAL Database & dataSupabase Row Level Security is off, or has no policy
Your app works, and the Supabase dashboard shows "RLS disabled" or "No policies" next to your tables.
Read the fix → CRITICAL Authentication & accessAdmin routes protected only by hiding the button
Your admin area is protected by something like {user.isAdmin && <AdminPanel />}.