Blog · 2026-08-06
anon key vs service_role key — the Supabase mistake that hands strangers your database
By the howsafeismyapp team
Every Supabase project comes with two API keys that look almost identical — two long JWT strings, side by side in the same dashboard panel. One is designed to be published to the world. The other bypasses every access rule you have and must never leave your server.
Mixing them up is one of the most damaging mistakes an AI-built app can ship, precisely because nothing breaks when you do: the app works *better* with the wrong key, since no security rule ever gets in the way. Here is how to tell them apart, how the swap happens, and how to check your own bundle in minutes. (Practical guidance, not legal advice.)
Two keys, opposite jobs
anon(public) key. Ships in your frontend by design. Every request made with it passes through Row Level Security — the per-row policies that decide what an anonymous or logged-in user may see. The key identifies your project; RLS does the protecting. How that works (and what happens when RLS is off) is its own topic: Row Level Security in Lovable apps.service_rolekey. Exists for your backend — server functions, cron jobs, admin scripts. Requests made with it bypass RLS entirely: full read and write on every table, no policy consulted. Supabase's dashboard marks it secret for a reason.
The mental model: the anon key opens the front door into a building where every room is locked by policy. The service_role key is the master key to every room. Publishing the master key means anyone on the internet can read your users table, rewrite rows, or delete data — regardless of how carefully you configured RLS. From the outside this looks like our scanner's most severe finding: admin key exposed in frontend code.
How the wrong key ends up in the bundle
Nobody decides to publish an admin key. It leaks through mundane paths, several of them AI-assisted:
- A generation step pastes it. You give an AI builder both keys in a prompt or an env file, and generated client code uses whichever variable made the query work.
- The env-var prefix trap. In Next.js, any variable prefixed
NEXT_PUBLIC_is compiled into the client bundle (Vite:VITE_). Naming a variableNEXT_PUBLIC_SUPABASE_SERVICE_KEYto "make it available" makes it available — to every visitor. - A debugging shortcut. RLS blocked a query during development; swapping in
service_role"fixed" it; the swap survived into production. - Server code migrated to the client. A helper written for an API route gets imported into a component, and the bundler dutifully ships its secrets.
None of these produce an error or a warning. The app works. That's the trap.
Check your own app in five minutes
- Open your deployed app in a private window with DevTools → Sources (or fetch the JS bundle files directly).
- Search the bundle for
service_roleand foreyJ(the start of any base64-encoded JWT). For each JWT you find, decode its middle segment — any JWT debugger oratob()in the console works — and read the"role"claim. "role": "anon"is fine. That key is meant to be there."role": "service_role"is an incident — continue below.- Check your repo too:
git grep service_role— keys in committed.envfiles or client source count, and note that shipped source maps expose original source including comments: source map publicly accessible. - While you're in the Network tab, run the logged-out table test from the RLS guide — an exposed admin key and a database readable without login are cousins, and apps that have one often have both.
If you found it: rotate first, refactor second
Treat an exposed service_role key as compromised the moment you find it — the bundle was public, so assume it was read.
- Rotate the key in the Supabase dashboard (Settings → API). The old key stops working; anyone who copied it loses access. Expect your backend to need the new value wherever it legitimately uses the key.
- Move every admin operation server-side. Whatever the frontend was doing with
service_rolebelongs in a Supabase Edge Function, a Next.js route handler, or another backend — where the key lives in a non-public environment variable and the client calls your endpoint, not the database. - Fix the env naming. Server secrets get no
NEXT_PUBLIC_/VITE_prefix, ever. Grep your codebase for prefixed variables and audit each one. - Assess what was reachable. With the key, everything was. Check Supabase's API logs for unfamiliar access patterns. Under Art. 32 GDPR an exposed admin credential is a failure of required technical measures; if personal data was actually accessed, Art. 33 GDPR breach-notification duties (72 hours from awareness) become the relevant question — one more reason to rotate immediately and look at the logs honestly.
- Re-verify from outside: search the freshly deployed bundle again. The only Supabase JWT in it should decode to
"role": "anon".
Common questions
Is the anon key a secret too?
No — it is public by design and ships in every Supabase-backed frontend. The protection layer is Row Level Security, not key secrecy. If seeing your anon key in the bundle worries you, the productive worry is whether RLS is actually enabled on every table.
Will rotating the service_role key break my app?
It invalidates the old key everywhere, so any *legitimate* server-side use needs the new value — update your backend's environment variables and redeploy. Your frontend should be unaffected, because the frontend should never have used it.
Can I use the service_role key in Edge Functions or API routes?
Yes — that is exactly what it is for. Server-side environments (Supabase Edge Functions, Next.js route handlers, cron jobs) keep the key out of anything a browser downloads. The rule is about *where the key travels*, not about avoiding it altogether.
How would anyone even find my key in the bundle?
The same way you just did: fetch the public JavaScript, search for JWTs, decode the role claim. It requires no skill and is easily automated — assume any secret in a public bundle is found, not hidden.
The one-minute outside check
Whether an admin key sits in your public bundle is visible to anyone — so it is worth being the first to look. Our free passive scan fetches your app the way any visitor's browser would and checks for exposed service-role keys alongside open tables, source maps, tracking and the legal-page basics. About a minute, no login, nothing stored.