Open your deployed app. View source. Ctrl+F for sk-.
If you just found your OpenAI key, you're not alone. When I reviewed 20 AI-built apps before launch, leaked keys in the frontend bundle were one of the findings I hit over and over. Not because the builders were careless. Because the tooling makes this specific mistake feel like the correct move.
Here's the mechanism, why Lovable and Bolt and v0 keep producing it, and how to catch it in about five minutes.
The mechanism: bundlers inline your env vars as strings
This is the part most people miss. An environment variable in a frontend app isn't a variable at runtime. It's a find-and-replace at build time.
When Vite builds your app, every reference to import.meta.env.VITE_OPENAI_KEY gets literally replaced with the string value of that key. Same with Next.js and process.env.NEXT_PUBLIC_ANYTHING. The bundler walks your code, swaps the reference for the raw value, and writes it into a JavaScript file that gets shipped to every visitor's browser.
There's no server in this picture. No auth check. The key is just sitting in dist/assets/index-a1b2c3.js as a plain string, and anyone can read it with view-source or a curl.
The VITE_ and NEXT_PUBLIC_ prefixes exist as a safety gate. They're the framework saying "I will only inline variables you've explicitly marked as public." Which works great, right up until someone prefixes a secret key to make an error go away.
Why AI builders do this constantly
Because the error message tells them to.
Here's the loop I've watched play out in real projects. You ask Lovable or Cursor to "add AI chat to my app." The generated code calls the OpenAI API directly from a React component. It references import.meta.env.VITE_OPENAI_API_KEY. You run it, it fails, the console says the variable is undefined. So you (or the AI, on the next prompt) add VITE_OPENAI_API_KEY=sk-proj-... to your .env file. Error gone. Chat works. Ship it.
Every step in that loop felt like fixing a bug. Nothing warned you that step three published your key to the internet.
The deeper issue is that AI tools default to client-side API calls because it's the shortest path to working code. A proper setup routes the call through a server endpoint (an API route, an edge function, whatever) where the key lives in server-only env vars and never touches the bundle. That's more files, more wiring, more chances for the generation to break. So the tools skip it unless you specifically ask.
Which keys actually matter here
Not every key in your bundle is a crisis, and knowing the difference saves you a panic.
A Supabase anon key in the frontend is fine, by design, as long as Row Level Security is actually doing its job. That's a whole separate failure mode and I wrote it up in the anon key post. A Stripe publishable key (pk_live_...) is also fine. These are built to be public.
The ones that hurt: OpenAI or Anthropic keys (someone runs their bill on your card, and yeah I've seen a four-figure surprise invoice from exactly this), Stripe secret keys (sk_live_, full account access), Supabase service_role keys (bypasses RLS entirely, your whole database), and basically any key whose docs say "server-side only." Resend, Twilio, AWS credentials. If the key can spend money or read data, it can't live in the bundle.
The 5-minute check
You don't need a security product for this. You need grep and your own build output.
Build the app locally, then search the output directory for key prefixes:
npm run build
grep -rE "sk-proj|sk_live|sk-ant|service_role|AKIA" dist/
Swap dist/ for .next/ on Next.js. If that grep returns anything, you've shipped a secret. Also worth a pass: grep for eyJ and inspect any JWTs you find, because a Supabase service_role key is a JWT and won't match the obvious prefixes. Paste it into a JWT decoder and check the role claim.
Second check, on the deployed app itself. Open devtools, Network tab, use the feature that calls the AI. If you see a request going straight to api.openai.com from the browser, the key's in the bundle. Has to be. The browser can't authenticate a request with a key it doesn't have.
Third, check your git history. Keys that were committed and later removed are still in the history, and scrapers watch public repos specifically for this. Gitleaks is free and takes about a minute to run against a repo.
Do this before every launch. It's genuinely faster than reading this post was.
When you find one
Rotate first, refactor second. The moment a key has been in a public bundle or a public repo, treat it as compromised. Generate a new one, kill the old one. Rotating after you refactor means the exposed key stays live while you're wiring up server routes.
Then move the call server-side. In Next.js that's an API route or server action reading process.env.OPENAI_API_KEY with no NEXT_PUBLIC_ prefix. On a Vite SPA you'll need an actual backend piece, and a single serverless function or Supabase edge function is usually enough. The frontend calls your endpoint, your endpoint calls the provider. The key never leaves the server.
And set a spend limit on the provider account while you're in there. Caps the blast radius if this ever happens again.
The pattern behind the pattern
The exposed key is rarely alone. In the launch reviews I've done, an app with a secret in its bundle almost always has siblings: missing RLS policies, an unprotected admin route, debug endpoints left on. The exposure report breaks down how often these travel together. It's the same root cause every time. AI tools optimize for "it works" and nothing in the loop ever asks "who else can do this?"
The grep above catches this one issue, and you should absolutely run it yourself. If you want a second set of eyes across the whole surface before you launch, that's what the Vibe Check Launch Risk Review is. I go through your app the way I went through those 20, and you get a prioritized list of what's actually exposed. One option among several. The free version is the grep, and honestly, start there.