Supabase service_role Key Leaked in Your Lovable or Bolt App? Here's the Fix
If you build with Lovable, Bolt, Cursor, Claude, Grok or ChatGPT on top of Supabase, your app works with two very different keys. One is meant to be public. The other is a master key. Mixing them up is one of the most damaging mistakes a vibe coder can make, and it's easy to do when an AI assistant is trying to make an error go away.
This guide explains the difference, what to do in the next hour if your service_role key leaked, and how to stop it happening again.
anon key vs service_role key: why one leak is fine and the other isn't
Every Supabase project has:
- The anon (public) key. Lovable and Bolt embed it in your frontend on purpose. It's safe to ship only if Row Level Security (RLS) is enabled on every table with policies that limit what each user can see.
- The service_role key. This key bypasses RLS entirely. Anyone who has it can read, edit and delete every row in every table, and use the admin API to manage your users.
(Supabase now recommends publishable (sb_publishable_…) and secret (sb_secret_…) keys instead, and is deprecating the legacy JWT-based anon and service_role keys by the end of 2026. The rule is the same: publishable is for the browser, secret never is. A secret key also bypasses RLS, but Supabase rejects it with a 401 if it is used from a browser, and you can create one per backend component and delete any single one that leaks.)
The anon key isn't a free pass either. In May 2025, researcher Matt Palmer disclosed CVE-2025-48757 (CVSS 9.3 as scored by MITRE; NVD has not published its own score, and Lovable disputes the CVE), covering Lovable projects that shipped with missing or weak RLS. The disclosure counted 303 exposed endpoints across 170 projects, where the public anon key in the page was enough to query tables containing user data, payment details and even other API keys. If a weak anon setup can do that, a leaked service_role key is worse: RLS doesn't even get a vote.
How service_role keys end up in vibe-coded apps
The usual patterns we see:
- "Permission denied" gets "fixed" with the wrong key. RLS blocks a query, you ask the AI to fix it, and it swaps in the service_role key. The error disappears, and so does your security.
- A
VITE_orNEXT_PUBLIC_prefix. Vite and Next.js copy any variable with these prefixes straight into the JavaScript sent to browsers.VITE_SUPABASE_SERVICE_ROLE_KEYis public the moment you deploy. - A committed
.envfile. The key is in your repo, and deleting the file later doesn't remove it from git history. - Hardcoded in a seed script or test file that got pushed to a public GitHub repo.
Step 1: Rotate the key now (before touching code)
Removing the key from your code does not un-leak it. It's already in old bundles, CDN caches, forks and git history. Assume someone has it.
Supabase's leaked-key procedure covers both key types:
- In your Supabase dashboard, open Settings → API Keys and create a new secret key (
sb_secret_…). - Replace the leaked key with the new secret key everywhere it's legitimately used: Edge Functions, your hosting provider's server environment variables, CI. If the leaked key was the legacy JWT-based
service_rolekey, this is also your move to the new key system. - Confirm every component uses the new key (the dashboard shows when each key was last used).
- Retire the leaked key:
- A leaked
sb_secret_key: delete it. This can't be undone, so finish step 3 first. - A leaked legacy
service_rolekey: deactivate the legacyanonandservice_rolekeys in the same screen. They can only be switched off together, so move your frontend to the publishable key (sb_publishable_…) and redeploy it first. Deactivation is reversible if you missed a client.
- A leaked
You don't need to rotate the JWT secret for a leaked service_role key. Only if the JWT secret itself leaked: migrate to JWT signing keys, rotate to the new signing key, then revoke the legacy JWT secret. Revoking it requires disabling the legacy anon and service_role keys first. On the legacy system, rotating the JWT secret signs out all current users.
Step 2: Check what was touched
Because service_role skips RLS, look for damage across the whole project:
- Auth → Users: any accounts you don't recognise, especially admins.
- Table data: unexpected rows, deleted rows, changed emails or roles.
- Storage: new or deleted files, buckets switched to public.
- Logs (API / Postgres): traffic spikes or requests from unfamiliar sources around the time the key went public.
If the tables held personal data and there's evidence it was accessed, you may have legal notification duties depending on where you and your users are. Get proper advice if that applies.
Step 3: Move privileged work to the server
The service_role key belongs only in code that runs on a server you control. In a Lovable or Bolt app that's usually a Supabase Edge Function:
// supabase/functions/admin-task/index.ts — runs on Supabase, never in the browser
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
// Supabase injects your secret keys as JSON; SUPABASE_SERVICE_ROLE_KEY still exists but holds the legacy key
const secretKeys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS")!);
const admin = createClient(Deno.env.get("SUPABASE_URL")!, secretKeys["default"]);
// Check who is calling before doing anything privileged!
// ...
return Response.json({ ok: true });
});
Your frontend keeps using the publishable (or legacy anon) key and calls this function. Two rules:
- Never give a server-only variable a
VITE_orNEXT_PUBLIC_prefix. - Inside the function, verify the caller (check their JWT and role). Otherwise you've just built a public endpoint that does anything the master key can.
A prompt you can paste into Lovable, Bolt or Cursor:
"Remove every use of the Supabase service_role key from frontend code. Use only the publishable (or anon) key in the browser. Move any operation that needs elevated access into a Supabase Edge Function that uses the Supabase secret key from the function environment (never a VITE_ or NEXT_PUBLIC_ variable) and checks the caller's JWT before acting."
Step 4: Fix the RLS that made you reach for service_role
Most service_role leaks start as an RLS problem. Fix the root cause:
-- See which public tables have RLS off
SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public';
-- Turn it on
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Let users manage only their own rows
CREATE POLICY "own rows" ON public.profiles
FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
Watch out for policies like USING (true). They switch RLS on without restricting anything. Tables created in the Supabase Table Editor get RLS on by default; tables created by SQL or migrations (which AI tools often generate) may not.
Then test as an attacker would: make a request with only the anon key and no login, and confirm it gets back nothing it shouldn't.
Step 5: Clean up the repo and prevent repeats
- If the key was committed, remove it from git history (see our guide on removing a committed
.envfile). Rotation comes first; history cleanup is hygiene. - Add
.env,.env.localand.env.*.localto.gitignore. - Keep a
.env.examplewith placeholder values only. - Turn on GitHub secret scanning and push protection for your repo.
Check your repo in under a minute
Not sure if a service_role key, or any other secret, is sitting in your code or recent history? Run a free ShipCheck scan on your GitHub repo (public, or private via Connect GitHub). It runs gitleaks across your current files and your last 100 commits, flags .env and .gitignore mistakes, checks your npm and Python packages for known vulnerabilities, and gives you a plain-English 0–100 score so you know what to fix first.
Built it with Lovable, Bolt, Cursor, Claude, Grok or ChatGPT? Is your repo safe to ship?
Paste a public GitHub repo, or connect GitHub for a private one, and get a risk score in under a minute. Free.
Built it with Lovable, Bolt, Cursor, Claude, Grok or ChatGPT? Is your repo safe to ship?
Paste a public GitHub repo, or connect GitHub for a private one, and get a risk score in under a minute. Free.