Supabase service_role key exposed in your frontend
Of all the keys that end up in AI-built frontends, this is the one that matters most. If it's in your JavaScript, anyone can read, change and delete everything in your database.
The problem: a master key in a public place
Supabase gives every project two kinds of keys. In its API keys documentation they're described plainly:
- Publishable key (
sb_publishable_..., or the legacyanonJWT): safe to put in a browser, because it only reaches what Row Level Security allows. - Secret key (
sb_secret_..., or the legacyservice_roleJWT): maps to theservice_rolePostgres role, which hasBYPASSRLS. Supabase's docs warn that it bypasses every RLS policy and must never go in a browser, a shipped app or source control.
How does it end up in a frontend? Usually innocently. An AI assistant hits a "permission denied" error caused by RLS, and the quickest change that makes the error go away is to swap in the key that ignores RLS. Or a .env variable gets a public prefix like VITE_ or NEXT_PUBLIC_, and the bundler copies its value into the JavaScript every visitor downloads. Vite's env docs state that VITE_* variables are bundled into your client code and shouldn't hold secrets.
Once it's there, your RLS policies stop mattering. Anyone who finds the key can use it from their own machine to dump your users table, change records or delete data. They don't need your app at all.
How to check if your service_role key is exposed
Option 1: search the shipped code
- Open your live site, then developer tools → Sources (Chrome) or Debugger (Firefox).
- Search across all files (Ctrl/Cmd+Shift+F) for
sb_secret_,service_roleandSERVICE_ROLE. - Then search for
eyJ. Supabase's legacy keys are JWTs, which start witheyJ. You'll likely find your anon key; that one's fine. The question is whether there's a second one.
Option 2: decode the JWT locally
A JWT has three parts separated by dots. The middle part is base64-encoded JSON, not encrypted. Decode it on your own machine rather than pasting a possible secret into a website:
# paste the middle section (between the two dots) in place of PAYLOAD
echo 'PAYLOAD' | tr '_-' '/+' | base64 -d 2>/dev/null; echo
If the output contains "role":"anon", that's the public key. If it contains "role":"service_role", you have a leak.
Option 3: check your source and env files
Search your repo for SERVICE_ROLE and SECRET_KEY. Any of them with a VITE_, NEXT_PUBLIC_, REACT_APP_ or EXPO_PUBLIC_ prefix is shipped to the browser by design. Also check that no createClient(...) call in a component, page or hook uses the secret key.
Option 4: run the free scan
ShipWatch downloads your page and its JavaScript bundles, decodes any Supabase JWTs it finds, and flags a service_role role or an sb_secret_ key as critical, showing only a masked preview of the key.
How to fix it, in the right order
Supabase's own guidance for a leaked secret key is: fix the root cause, then rotate. Here's what that looks like.
1. Move privileged work to the server
Figure out why the frontend needed the master key. Usually it's one of three things: an admin screen, a write that RLS blocked, or a call to another service. Each one belongs in a server route or a Supabase Edge Function that checks who the user is before doing anything.
My Supabase service_role / secret key is in frontend code. Find every place it is used. Replace the frontend Supabase client so it only ever uses the publishable (anon) key. For each operation that needed elevated access, create a Supabase Edge Function that verifies the signed-in user and their permissions before doing the work, and call that function from the frontend instead. Make sure no VITE_ or NEXT_PUBLIC_ variable contains a secret.
2. Fix the RLS problem that started it
The app was using the service_role key to get around Row Level Security errors. Now that it uses the anon key, write correct RLS policies so signed-in users can do exactly what the app needs and nothing more: one policy per operation, "to authenticated", "(select auth.uid()) = user_id", and WITH CHECK on every insert and update.
The Supabase RLS checker page walks through testing those policies.
3. Rotate the key
Removing the key from your code doesn't revoke it. Old builds may be cached, and anyone who copied it still has it. Following Supabase's rotation steps: create a new secret key in Settings → API Keys, update your server-side code to use it, confirm everything works, then deactivate the legacy keys (or delete the leaked secret key). Supabase recommends moving from the legacy JWT keys to the new publishable and secret keys anyway; its docs say the legacy keys are being deprecated.
4. Look for damage
Check your tables for rows or changes you don't recognize, review your Supabase logs for unusual API activity during the time the key was public, and decide whether you need to tell your users. If the key was ever committed to git, treat the git history as exposed too.
Add a check to my build so it fails if any file in the client bundle contains "service_role", "sb_secret_" or a JWT whose payload role is service_role. Also add .env and .env.* to .gitignore.
Keep it from happening again
- Name secrets without public prefixes:
SUPABASE_SECRET_KEY, neverVITE_SUPABASE_SECRET_KEY. - When your AI suggests "use the service role key to fix this", say no and ask for an RLS policy or an Edge Function.
- Rescan after every deploy. A one-line change can bring it back.