howsafeismyapp

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_role key. 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.

Where do I find the anon key in Supabase?

Both keys live on the same screen, so it is worth being precise about which one you are copying.

In the dashboard: open your project, then Project Settings → API. Under Project API keys you will see the project URL and two values. The anon key — labelled anon public, and on newer projects Publishable key — is shown openly, with a copy button, because it is meant to be public. Copy that one into your frontend, typically as VITE_SUPABASE_ANON_KEY or NEXT_PUBLIC_SUPABASE_ANON_KEY; the NEXT_PUBLIC_/VITE_ prefix is correct here and only here.

Two ways to confirm you took the right key before shipping it:

  • Decode it. Paste the JWT into any decoder, or run atob(key.split('.')[1]) in a browser console, and read the "role" claim. "anon" is the public key. "service_role" is the one that must never reach a browser.
  • Look at how it is presented. The anon key is visible by default; the secret key sits behind a reveal control and is marked secret. If you had to click to unhide it, it does not belong in your frontend.

If you prefer the CLI, supabase projects api-keys --project-ref <ref> lists both with their names.

What is an anon key, exactly?

It is a JWT that identifies your project and carries the role anon — nothing more. It grants no permissions by itself. Every request made with it is evaluated against Row Level Security, so what a holder can actually read or write is whatever your policies allow for an unauthenticated (or, after login, an authenticated) user.

That is why publishing it is safe *by design* and also why it is only as safe as your policies: with RLS enabled and written, the anon key is a doorbell; with RLS off, it is an open door. When a user signs in, the client swaps in that user's access token for subsequent requests, and policies see their real auth.uid() — the anon key's job is finished at that point.

The practical consequence: seeing your anon key in the page source is not a finding. A table that answers without login is.

Where to find your service_role key

In the Supabase dashboard: open your project, then Project Settings → API. Both keys are listed there — the anon / publishable key openly, the service_role key behind a reveal control and marked secret. Newer projects show these under API Keys, with the same split between a publishable key and a secret one.

Two things worth knowing while you are on that screen. Rotating the service_role key is done from the same page, and it takes effect immediately — anything still using the old value breaks, which is exactly why you rotate first and refactor second if the key has leaked. And the moment you copy that value, decide where it is going: an environment variable read by server code is fine, anything that reaches a browser bundle is not. Which of the two you are actually shipping is the whole subject of this post.

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 variable NEXT_PUBLIC_SUPABASE_SERVICE_KEY to "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

  1. Open your deployed app in a private window with DevTools → Sources (or fetch the JS bundle files directly).
  2. Search the bundle for service_role and for eyJ (the start of any base64-encoded JWT). For each JWT you find, decode its middle segment — any JWT debugger or atob() in the console works — and read the "role" claim.
  3. "role": "anon" is fine. That key is meant to be there. "role": "service_role" is an incident — continue below.
  4. Check your repo too: git grep service_role — keys in committed .env files or client source count, and note that shipped source maps expose original source including comments: source map publicly accessible.
  5. 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.

  1. 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.
  2. Move every admin operation server-side. Whatever the frontend was doing with service_role belongs 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.
  3. Fix the env naming. Server secrets get no NEXT_PUBLIC_/VITE_ prefix, ever. Grep your codebase for prefixed variables and audit each one.
  4. 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.
  5. 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.

Check your app against all of this — free

passive · no login · we store nothing

Related posts

← All posts