Did you leak a Supabase service_role key? (It bypasses every security rule you wrote)
Which Supabase key did you put in your frontend? You did the responsible thing everywhere else. You turned on row-level security, wrote policies so users can only read their own rows, tested it, watched it block the queries it should block. Your data is locked down. All of that rides on which of two keys ended up in the browser.
Supabase hands you both from the same settings page. The anon key is public by design and respects every RLS policy you wrote. The service_role key is the master key. It's marked "secret" in the dashboard, and it bypasses row-level security completely. So if the master key shipped to the browser, your policies still exist. They just stop applying to anyone holding it.
How the wrong key gets shipped
The two keys sit next to each other on the API settings page, both long JWT strings starting with eyJ, both easy to copy in a hurry. The anon key belongs in NEXT_PUBLIC_SUPABASE_ANON_KEY. The service_role key belongs only on your server. Paste the service_role value into a public-prefixed variable, or into a createClient call in a component, and the build inlines it into the bundle for everyone.
It usually happens when you hit a wall. A query keeps failing because RLS is doing its job and blocking it, you're tired of fighting policies, and you swap in the service_role key to "just make it work." It does work, because that key skips the check. So the friction disappears and the key ships. Same trap as an AWS key in the build: the credential that's easiest to make work is the one that should never leave the server.
You can tell which key you have without guessing. Both are JWTs, so paste the payload into any JWT decoder and read the role claim. anon is safe for the browser. service_role is not.
What a stranger does with it
This is the part that makes a service_role leak worse than most. The attacker doesn't need to find a flaw in your policies, because the key tells the database to ignore the policies entirely.
They open your site, search the bundle for service_role or eyJ, decode the token, and confirm the role. Then they point the Supabase client at your project URL, which is also right there in the bundle, and run any query they want. Every row in every table, regardless of who owns it.
Read is the start. The service_role key writes too, so they can update records, delete tables, drop your data, or quietly insert rows. It also reaches the parts of your project the anon key never touches: the auth schema with your user records, storage buckets, anything behind the service role. There is no policy to violate because there is no policy in the way.
You won't see an alert. Supabase sees authenticated calls signed with a valid service_role key and treats them as your backend, because that's what the key is for. The first sign is usually missing data or a customer asking why their account changed.
Check it from outside, then rotate
You can check in under two minutes, no tools required.
- Open your site in Chrome or Firefox and open developer tools (F12).
- In the Sources tab, search all loaded files (Ctrl+Shift+F, or Cmd+Option+F on a Mac).
- Search for
service_role. Then search foreyJand decode any JWT you find, checking theroleclaim.
If you find a token whose role is service_role, it's public, and you should treat your whole database as readable by strangers from the moment that bundle deployed.
Two steps, in order.
Rotate the service_role key first. In Supabase, go to project settings and reset the service role key. This invalidates the leaked one. Do it before redeploying, because the old bundle stays cached and the old key stays valid until you roll it. Once rotated, review your data for changes you didn't make.
Then keep service_role on the server. The only Supabase key that belongs in the browser is the anon key, and it's only safe because RLS is enforced against it. Any operation that genuinely needs to bypass RLS belongs in a server route or an edge function that holds the service_role key and never exposes it.
// lib/supabase.ts (shipped to the browser) const supabase = createClient( "https://abcd.supabase.co", // service_role JWT, bypasses every RLS policy "eyJhbGciOiJ...role:service_role...", );
We scan your bundle from the outside for service_role JWTs, the same view an attacker's crawler has, and flag one if it's there. What we can't do is read your policies or test whether RLS is correctly written. That's the honest limit: we see the master key escaped into the browser, which is the failure that makes the rest moot. Whether your policies were any good stops mattering the second that key ships.
Find it before someone else does.
Paste your domain. The grade and issue count are free, and you'll see in a couple of minutes exactly what's reachable from outside.