Where exposed keys hide in Lovable and Bolt builds
Supabase service_role keys, Stripe secrets and OpenAI keys end up in the browser bundle more often than you'd think. How to find them on your own live site, and how to rotate them safely.
Wajahat Shaw · Founder, Gitrex Technologies
4 min read
First, the key that is supposed to be public. The Supabase anon key (Lovable calls it the publishable key, Bolt puts it in VITE_SUPABASE_ANON_KEY) is designed to ship in the browser. Finding it in your bundle is not a finding. Row-level security is what protects the data behind it, and that's a separate conversation.
Everything below is about the keys that aren't supposed to be there.
How they get in
Vite, which both Lovable and Bolt build on, inlines any environment variable prefixed VITE_ into the client bundle. That's the intended behaviour, and it's the trap. Someone needs a Stripe call to work, they add VITE_STRIPE_SECRET_KEY because the other Stripe variable already had that prefix, and the secret is now in a public JavaScript file with a permanent URL.
The other route is more subtle. The app calls Supabase with the anon key, hits an RLS error, and the tool's fix is to swap in the service role key "so it works". The service role key bypasses RLS entirely. In a server it's a powerful tool. In a browser it's the master key to the database, handed to every visitor.
Then there's the .env file committed to the repo, which I still see, and the key pasted straight into a supabaseClient.ts file as a string literal, which I see more.
How to check your own site in five minutes
Open the live site. Open DevTools. Go to the Sources tab and use the global search (Cmd+Option+F on a Mac, Ctrl+Shift+F elsewhere). Search for each of these:
| Search for | What it is | Should it be there? |
|---|---|---|
eyJ | Start of every Supabase JWT | Only if the role claim is anon |
service_role | Supabase admin key, or a reference to it | No |
sk_live_ | Stripe live secret key | No |
sk_test_ | Stripe test secret key | No, and it means live was probably handled the same way |
sk- | OpenAI / Anthropic-style API keys | No |
re_ | Resend API key | No |
AKIA | AWS access key id | No |
For each eyJ hit, copy the whole token and decode it. Any JWT decoder will do; it's base64, not encryption. Look at the role field. anon is fine. service_role is the finding.
If you'd rather do it from a terminal, download the built bundle and grep it:
curl -s https://yourapp.com | grep -oE 'src="[^"]+\.js"' | sed 's/src="//;s/"//' \
| while read f; do curl -s "https://yourapp.com$f"; done \
| grep -oE '(sk_live_[A-Za-z0-9]+|sk-[A-Za-z0-9]{20,}|re_[A-Za-z0-9]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)' \
| sort -u
Also check the git history, because rotating a key that's still in an old commit on a public repo doesn't help:
git log -p --all -S 'service_role' -- . ':!node_modules' | head -50
If you found one: rotate first, investigate second
Don't spend an hour working out how it got there while it's still live. Rotate, then trace.
Supabase service role key. Project Settings, API, then the rotate option next to the service role key. Update it in the one place it should live, which is your server-side environment (Vercel project settings, Supabase Edge Function secrets), and nowhere in the client. If your only server is "there isn't one", that's the real problem, and the call that needed the key has to move into an Edge Function or an API route.
Stripe. Developers, API keys, then "Roll key" on the secret. Stripe lets you set a grace period so the old key keeps working for up to a day while you deploy. Use it.
OpenAI, Anthropic, Resend. Delete the key, create a new one. There's no grace period, so have the new one ready to deploy first. Check the usage dashboard while you're there; a leaked LLM key tends to show up as a spike from a region you've never sold to.
The .env in git. Rotating is not enough if the repo is public. Rewriting history is possible but painful. If the repo is private and always has been, rotate, add .env* to .gitignore, and move on.
Making it not happen again
Two rules, and they're both boring.
Anything that needs a secret runs on a server: a Supabase Edge Function, a Next.js route handler, a Vercel serverless function. The browser talks to that, and that talks to Stripe or OpenAI. If the generated code has the browser talking directly to a paid API, it is wrong even if the key is currently safe, because the next "fix" will put it there.
And a pre-deploy check that greps the built output for the patterns in the table above. Ten lines of shell in CI. It's the cheapest security control you'll ever add.
Key exposure is one of the first things I look for in an AI App Launch Audit, partly because it's so quick to check and partly because it's the one finding that can't wait for the report.

Wajahat Shaw
Founder of Gitrex Technologies. Senior engineer; builds and rescues software for founders, most of it React Native, Next.js and Supabase. Writes here about what actually breaks and how to check for it. More about Gitrex
