Why this happens
It usually starts with an RLS error. The AI (or a tutorial) "fixes" a blocked insert by creating the client with the service_role key, often in a variable like VITE_SUPABASE_SERVICE_ROLE_KEY or NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY. Anything with a VITE_ or NEXT_PUBLIC_ prefix is compiled into the public JavaScript, so anyone who opens DevTools can copy it.
With that key, someone can read and write every table, ignore your policies, manage storage, and use the Auth admin API. Supabase's newer sb_secret_ keys are rejected when used from a browser (HTTP 401), but they can still be used from any script once leaked. So the fix is the same: keep them out of shipped code.
Which key is in your bundle?
Supabase projects have a public key and a secret key, in two generations:
- New keys say what they are.
sb_publishable_...is meant for the browser.sb_secret_...must never ship. - Legacy keys are JWTs that start with
eyJ. Decode the middle segment (it's base64, not encrypted) and read theroleclaim."role": "anon"is expected in client code."role": "service_role"is an incident.
The public key is only as safe as your Row Level Security. A lot of AI-built apps leave RLS off because the first tutorial still returned data. In that case the public key isn't the bug, the missing policy is. Treat a 200 with rows from /rest/v1/your_table as a sign a policy is missing, not as a passing test.
How to fix it, step by step
1. Fix the root cause first (Supabase's own advice is not to rotate until the leak is fixed). Find every use:
grep -rnE "service_role|SERVICE_ROLE|sb_secret_|SECRET_KEY" src/ .env* 2>/dev/nullMove each privileged action into an Edge Function or server route that checks who is calling. In the browser, use only the publishable (or anon) key.
// supabase/functions/admin-action/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req) => {
const url = Deno.env.get('SUPABASE_URL')!;
const token = (req.headers.get('Authorization') ?? '').replace('Bearer ', '');
// 1. Who is calling? Verify the user's own session token.
const { data: { user } } = await createClient(url, Deno.env.get('SUPABASE_ANON_KEY')!).auth.getUser(token);
if (!user) return new Response('Unauthorized', { status: 401 });
// 2. Check this user is allowed to do the action (e.g. look up their role).
// 3. Only now use the privileged client, server-side.
const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!);
const admin = createClient(url, secretKeys['default']);
// ... privileged work with admin ...
return Response.json({ ok: true });
});Add CORS headers as shown in our Edge Function CORS guide if the browser calls it.
The front end then calls it with supabase.functions.invoke('admin-action', { body }). In your own server code, read the key from a non-public env var such as SUPABASE_SECRET_KEY.
2. Rebuild and confirm the key is gone from the shipped files:
npm run build
grep -rl "sb_secret_" dist/ .next/static/ 2>/dev/null
# Legacy keys are JWTs: decode any you find and look for "role":"service_role"
grep -rhoE 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' dist/ 2>/dev/null | sort -u3. Rotate, following Supabase's procedure:
- Create a new secret key in Settings → API Keys.
- Put it everywhere your servers and functions need it.
- Confirm everything works with the new key.
- Retire the leaked one. Delete a leaked
sb_secret_key. For a leaked legacyservice_roleJWT, deactivate the legacy keys in the same section. Your front end must already use the publishable key, because the legacy anon key goes with them. Supabase notes that deactivating is reversible.
4. Look for damage. Check the API and Postgres logs for requests you don't recognise. Review recently created users and admins, unexpected row changes, and storage objects. If you find any, restore from a backup.
5. Clean up history. The key is still in old deploys and git history. That's why rotation, not deletion, is what protects you.
Fix prompt for your AI builder
Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.
Our Supabase service_role / secret key is exposed in front-end code. Please:
1. Find every place the service_role or secret key is used, including any VITE_ or NEXT_PUBLIC_ env var with SERVICE_ROLE or SECRET in its name. Remove it from all browser code.
2. Move each privileged operation into a Supabase Edge Function (or server route) that verifies the signed-in user and their permission before doing anything. Call it with supabase.functions.invoke.
3. Make the browser client use only the publishable/anon key.
4. Keep RLS on, and add any policies the browser now needs for normal user actions.
Never print the key in chat, logs or commits. After this, I'll rotate the key in the Supabase dashboard.How to catch this next time
This leak tends to come back. The next RLS error, the next AI edit, and the key is back in a public variable.
Heedza's passive scan reads your homepage, its inline scripts and up to six same-origin JavaScript bundles. It decodes legacy Supabase JWT keys and raises a high finding for a service_role key, and a critical one for a new sb_secret_ key, each with a fix prompt for your AI tool. It also flags live Stripe secret keys, OpenAI-style keys, GitHub and AWS tokens, and publicly readable /.env or /.git files. On monitored apps the scan re-runs with each check and emails you when something new appears.
Reading actual rows is a separate deep check. It runs only after you prove the domain is yours (DNS TXT record or meta tag), requests at most three tables with limit=1, and never writes. The scan doesn't crawl every lazy-loaded chunk, so keep the grep check above in your release routine. Run the free scan on your live URL now.