Gitrex Technologies
SecuritySupabaseBoltLovable

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.

· 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 forWhat it isShould it be there?
eyJStart of every Supabase JWTOnly if the role claim is anon
service_roleSupabase admin key, or a reference to itNo
sk_live_Stripe live secret keyNo
sk_test_Stripe test secret keyNo, and it means live was probably handled the same way
sk-OpenAI / Anthropic-style API keysNo
re_Resend API keyNo
AKIAAWS access key idNo

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

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

Keep reading