Is the Supabase anon key safe to expose? Anon vs service_role
The anon or publishable key is meant to be public; the service_role or secret key must never reach the browser. How to tell which one your app ships, and fix it.
· 7 min read
You open DevTools on your own app, look at a request to supabase.co, and there it is: a long key in the apikey header, visible to anyone. Or a scanner, a friend or a Reddit thread tells you "your Supabase key is exposed". Is that a leak you need to fix tonight, or how Supabase is supposed to work?
It depends entirely on which key it is. Supabase gives every project two kinds of keys, and they look similar enough that AI assistants mix them up. One is designed to be public. The other gives full access to your database. This guide shows how to tell them apart in a minute, what to do if the wrong one is in your frontend, and how to keep it out.
Two kinds of keys, one rule
Supabase currently has two generations of API keys. The API keys documentation describes them like this:
- Publishable key (
sb_publishable_…), and its legacy version, the anon key. Low privilege. Supabase says it is safe to expose in a web page, a mobile app or source code. - Secret key (
sb_secret_…), and its legacy version, the service_role key. Full access to your project's data. It maps to theservice_roledatabase role, which has theBYPASSRLSattribute, so Row Level Security policies never apply to it.
The rule that follows is short: the publishable or anon key belongs in the browser; the secret or service_role key never does.
The legacy keys are both JWTs: long strings starting with eyJ. They look almost identical. The only difference is a role field inside the token, which is why it is easy to paste the wrong one into a .env file without noticing.
Why "public" does not mean "harmless"
The anon key being public is fine only because something else does the protecting: Row Level Security. The key identifies requests as coming from your app; RLS policies decide which rows each request may see. With RLS off on a table, the public key reads that whole table.
So if someone tells you the anon key is exposed, the right answer is "yes, by design", followed by "and every table has RLS with sensible policies". If you are not sure about the second part, check RLS first.
A few other things the anon key can reach without any secret, worth knowing about:
- Database functions that are exposed through the API and callable by the
anonrole (supabase.rpc(...)in your code). - Storage buckets marked public, and any bucket whose policies allow anonymous access.
- Sign-ups, if email or password sign-up is enabled. Anyone can create an account and then act as
authenticated, so policies that check only "is logged in" protect less than they seem to.
None of these is a key problem. They are policy problems, and they are where to look next once the keys are in order.
Why AI-built apps end up with the wrong key
The secret key "just works". A query blocked by a missing policy succeeds instantly with the service_role key, because it skips RLS. When an AI assistant is told "the dashboard shows no data, fix it", swapping in the more powerful key is one of the fastest ways to make the error go away.
The other common route is environment variables. Frameworks inline variables with a public prefix into the browser bundle: VITE_ in Vite, NEXT_PUBLIC_ in Next.js. A line like VITE_SUPABASE_SERVICE_ROLE_KEY=... in your .env means the secret is shipped to every visitor, even though it "lives in an env file". Minification does not hide it; it only renames the variable.
Check it yourself
1. Find the key your live site actually uses
Open your production site, then DevTools → Network. Reload, filter by supabase.co, click any request and look at the apikey request header. That is the key every visitor has.
2. Identify it
If it starts with sb_publishable_, you are fine. If it starts with sb_secret_, it is the secret key and needs fixing now.
If it starts with eyJ, decode the middle part and read the role. This runs locally, nothing is sent anywhere:
node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url')))" "PASTE_THE_KEY_HERE"
"role": "anon" is the public key. "role": "service_role" is the secret one.
3. Search everything the browser loads
The key in the request header is the one your client uses, but a second key can sit unused in a bundle. In DevTools, open the search across all loaded files (Cmd + Option + F on macOS, Ctrl + Shift + F on Windows and Linux) and search for:
sb_secret_service_roleSERVICE_ROLE
Then check your source for public-prefixed variables that hold a secret:
grep -rnE "(VITE|NEXT_PUBLIC|EXPO_PUBLIC)_[A-Z_]*(SERVICE|SECRET)" --include="*.env*" --include="*.ts" --include="*.tsx" --include="*.js" .
Any hit is a secret that the build may put in the browser. The longer walkthrough in how to find API keys in your JavaScript bundle covers source maps and lazy-loaded chunks.
4. Check where the secret key is supposed to live
A secret key in a backend is normal: an Edge Function, a server route, a cron job. Inside Supabase Edge Functions the keys are injected for you, and the Edge Function secrets guide lists them: SUPABASE_SECRET_KEYS for the new keys, SUPABASE_SERVICE_ROLE_KEY for the legacy one. You should not need to paste it anywhere yourself.
How to fix a secret key in the frontend
Do these in order.
- Treat the key as compromised. It has been downloadable by anyone who opened the site. Removing it from the code does not take it back.
- Move the privileged code to the server. Whatever needed the secret key, usually an admin query or a write that RLS blocked, goes into an Edge Function or server route. The browser calls that function with the user's session; the function checks who the user is before doing anything.
- Fix the policy that made the secret key "necessary". Often the real bug is a missing RLS policy. Add the policy and the anon key works again.
- Rotate. In the dashboard, Settings → API Keys. For a new-style secret key: create a new one, update every server component that uses it, confirm they work, then delete the old key. For the legacy JWT keys, Supabase's documented route is to move your app to the publishable and secret keys and then deactivate the legacy keys in the same section. Deactivation is reversible, so you can re-enable them if you missed a client.
- Redeploy and check again with steps 1 to 3 above. The fix is done when the live site contains only the publishable or anon key.
A prompt for Lovable, Cursor or Claude Code
Audit how this app uses Supabase keys.
1. The browser must only ever use the publishable key (sb_publishable_...)
or the legacy anon key. Find every place the secret key (sb_secret_...),
the service_role key, or any env variable containing SERVICE or SECRET
is read in client code, including variables with VITE_, NEXT_PUBLIC_
or EXPO_PUBLIC_ prefixes. List them before changing anything.
2. For each place that needs elevated access, move the logic into a
Supabase Edge Function. Inside the function, verify the caller's JWT
and check that the user is allowed to do the action before using the
secret key.
3. Where the secret key was only used to get around a missing policy,
remove it and write the Row Level Security policy instead.
4. Remove the secret key from all .env files that the frontend build reads.
Show me the diff and the SQL for any new policies before applying them.
After the AI is done, rotate the key yourself. The assistant cannot do that part for you, and the old key stays valid until you do.
Keep it from coming back
The wrong key rarely arrives on launch day. It arrives three weeks later, when a new admin screen does not load and the quickest fix is the powerful key. Nothing in the app looks different afterwards.
ScanYourSite's Supabase check watches for exactly this from the outside:
- The free check searches your page and the scripts it loads for
sb_secret_keys and for JWTs with theservice_rolerole, shows them redacted with the file they were found in, and notes which project and public key type the app uses. A publishable or anon key is reported as a note, not a problem. - On Pro, after you verify the domain, it also asks Supabase, with your site's public key, how many rows each table your code queries returns to a stranger. It counts rows and never reads their contents.
- Pro runs this daily and after each deploy, and tells you when something changes.
Whether you use a tool or a checklist, the habit that matters is the same: after every change to how your app talks to Supabase, look at what the live site ships, not at what you think is in the code.