Is the Supabase anon key safe to expose? Publishable vs secret keys
Short answer: yes, the Supabase anon key, and its replacement the sb_publishable_ key, is meant to be shipped in your frontend, but only when Row Level Security (RLS) is on for every table and view it can reach, because RLS is what stands between that key and your data. The matching secret keys, service_role and sb_secret_, bypass RLS and must never reach a browser, a mobile app or your repository. If one did, create a new secret key, deploy it, confirm nothing still uses the old one, and only then retire the old key.
The four Supabase keys
| Key | What it looks like | Postgres role | In the browser? |
|---|---|---|---|
| Publishable | sb_publishable_... | anon when signed out, authenticated when signed in | Yes, with RLS |
| Secret | sb_secret_... | service_role | Never |
anon (legacy) | a long JWT starting eyJ, with "role":"anon" | anon | Yes, with RLS |
service_role (legacy) | a long JWT starting eyJ, with "role":"service_role" | service_role | Never |
Supabase describes the publishable key as low privilege and "safe to expose online", and the secret key as elevated, "only use in backend components" (Supabase: API keys). The new keys are short strings, not JWTs. Supabase says it is deprecating anon and service_role "by the end of 2026", and that both sets of keys work at the same time, so creating new keys does not switch the old ones off (Supabase: JWT signing keys). The publishable key carries the same low privileges as the anon key, so your RLS policies behave the same after you swap it in (Supabase: migrating to new API keys).
What a publishable or anon key can and can't do
The key only picks a Postgres role. Postgres checks table grants first and then applies RLS, so what the key can do depends on both:
| State of the table | What anyone holding the public key can do |
|---|---|
RLS off, anon has grants | Read and write it. Supabase: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it" (Supabase: RLS). On existing projects, new tables in public have been created with every privilege granted to anon, authenticated and service_role (Supabase: securing your API). |
| RLS on, no policies yet | Nothing. Supabase: once RLS is on, no data is accessible with a publishable key until you create policies. |
RLS on, a policy to anon using ( true ) | Read every row. Correct for data that is meant to be public, a leak for anything else. |
RLS on, owner-only policies to authenticated | Signed out: nothing. Signed in: only that user's own rows. |
A view without security_invoker = true | Whatever the view's owner can read, because views bypass RLS by default. See the view-bypass guide. |
Two things the key does not do. It doesn't hide itself: Supabase lists reverse engineering, the browser's network inspector, XSS, CSRF, phishing and man-in-the-middle attacks as things publishable keys "aren't intended to protect against", because anyone can retrieve it from a public component. And "publishable" doesn't mean "anonymous": the same key is used by signed-in users, who then run as authenticated, so your policies need to cover both roles.
One date to know: on October 30, 2026 Supabase applies its "new tables are not exposed to the Data API by default" change to all existing projects. New tables in public then need an explicit grant, but existing tables "keep their current grants and stay reachable" (Supabase changelog). A table you created last year with RLS off is not made safe by that change. Also, Supabase scheduled April 8, 2026 as the date all existing projects stop serving the API's schema listing to the anon key (Supabase changelog), but table names are still easy to find in your own JavaScript.
Why service_role and sb_secret_ must never ship
- They skip RLS entirely.
service_rolehas theBYPASSRLSattribute, so policies never apply to it. Anyone with the key can read and change every table you expose. Supabase: "Never put one in a browser, a shipped application, or source control." - A 401 in your browser is not protection. Supabase's
sb_secret_keys return HTTP 401 when they are used from a browser (matched on theUser-Agentheader), but the same page notes "an attacker can still use the key from other tools". If one is in your bundle, treat it as leaked. - Env var prefixes ship it for you. Anything named
VITE_,NEXT_PUBLIC_,REACT_APP_orPUBLIC_is compiled into the JavaScript every visitor downloads. Supabase warns about exactly this for the JWT shared secret in its signing keys docs. Put secret keys in server-only secrets (an Edge Function, an API route or a Worker).
How to check your own app
1. See which keys your site ships. Open the live site, view the loaded scripts in DevTools (Sources, or Network filtered to JS) and search for sb_secret_, service_role and sb_publishable_. For a legacy key starting eyJ, read the middle part to see its role claim, locally rather than in an online decoder:
echo 'PASTE_THE_KEY' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
"role":"anon" is the public key. "role":"service_role" is a leak: go to the rotation steps below.
2. Test what the public key can read. Only test your own project, and use the publishable or anon key your site already ships, never a secret key. The new keys go in the apikey header:
curl "https://YOUR-PROJECT-REF.supabase.co/rest/v1/YOUR_TABLE?select=*&limit=1" -H "apikey: YOUR_PUBLISHABLE_OR_ANON_KEY"
- A row comes back for data that should be private: the table (or view) is open. Fix it below.
[]: no rows visible toanon. It's also what an empty table looks like, so make sure the table has rows when you test.- 401, 404 or a
42501permission error:anoncan't reach it at all. That's the result you want for private tables.
3. Check the database itself. In the Supabase SQL Editor:
-- tables with RLS off (rowsecurity = false) come first
select tablename, rowsecurity from pg_tables where schemaname = 'public' order by rowsecurity, tablename;
-- policies that let everyone through (check the roles column for anon or public)
select tablename, policyname, roles, cmd, qual, with_check from pg_policies where schemaname = 'public' and (qual = 'true' or with_check = 'true');
-- what anon and authenticated can touch (tables and views)
select table_name, grantee, string_agg(privilege_type, ', ' order by privilege_type) as privileges
from information_schema.role_table_grants
where table_schema = 'public' and grantee in ('anon', 'authenticated')
group by table_name, grantee order by table_name, grantee;
To close a gap on a table that should be private (swap reports and user_id for yours):
alter table public.reports enable row level security; revoke all on table public.reports from anon, authenticated; grant select, insert, update, delete on table public.reports to authenticated; create policy "Users can read their own reports" on public.reports for select to authenticated using ((select auth.uid()) = user_id); -- add insert, update and delete policies the same way (with check for insert and update)
Adding a policy doesn't remove a grant, which is why the revoke is there. More patterns, and what to do about views, are in Supabase RLS for vibe-coded apps and the view-bypass guide.
If a secret key leaked: rotate, then revoke
This is Supabase's order (Supabase: API keys). Don't skip steps, and don't retire the old key before the new one is live, or you'll take your own backend down. Supabase also says to delete a leaked key immediately, so don't leave the old one active longer than the steps need.
- Fix the root cause first. Remove the key from frontend code and from any
VITE_orNEXT_PUBLIC_variable, or the next deploy ships the new key too. - Create a new secret key (Dashboard, Settings, API Keys). You can create one per backend component, so a later leak only forces one rotation.
- Replace the old key everywhere your backend uses it. A legacy
service_rolekey is replaced by a secret key. - Confirm every component, including Edge Functions and scheduled jobs, now uses the new key.
- Then retire the old key. Deleting a secret key can't be undone, so wait until step 4 is true. Legacy
anonandservice_rolekeys are deactivated in the same Settings page, which Supabase says you can reverse.
Then assume anything the leaked key could read may have been read, and review what it could reach. A leaked legacy service_role key does not mean you must rotate your JWT secret: Supabase says that if the JWT secret is secure you can substitute a new secret key and avoid downtime. Revoking the legacy JWT secret is a separate step, and Supabase requires you to disable anon and service_role first, because both are JWTs signed with it. Rotating a signing key on its own doesn't revoke the old one; revoking does.
A visible anon or publishable key, on its own, needs no rotation. Moving from the legacy keys to the new ones is worth doing before the end-of-2026 deprecation: create the keys, swap the publishable key into client code, swap the secret key into backend code and Edge Functions, check nothing still uses the legacy keys, then deactivate them. See Supabase: migrating to new API keys. The key-handling basics for other providers are in exposed API keys in JavaScript bundles.
Paste-in fix prompt
For Lovable, Bolt, Cursor, Replit or any AI coding tool. Review what it changes before you accept it:
My app uses Supabase. Please do these in order and show me the changes and the SQL before applying anything: 1. Search the whole repo and any build output for service_role, sb_secret_, and any JWT whose role claim is service_role. Remove every one from frontend code and from env vars prefixed VITE_, NEXT_PUBLIC_, REACT_APP_ or PUBLIC_. If you find one, tell me, because I must create a new secret key, deploy it, confirm nothing uses the old one, and only then retire the old one. 2. The browser client must use only the publishable key (sb_publishable_...) or the legacy anon key. 3. Anything that needs elevated access must run server-side (Edge Function or API route) and read a secret key from a server-only secret. 4. For every table and view in the public schema: list who can read and write it with the anon and authenticated roles. Enable RLS on every table, revoke all from anon and authenticated, grant back only what the app needs, and write owner-only policies using (select auth.uid()) = user_id. Views must use security_invoker = true. 5. Do not disable RLS, do not write using (true) policies for private data, and do not grant anything to anon unless the data is meant to be public. Do not change unrelated code.
Then repeat check 2 against your published site.
What LatchDog does here (and doesn't)
LatchDog keeps watching your live app after launch. Starter ($19/month, up to 3 apps) and Pro ($39/month, up to 10 apps) re-scan it automatically every day and after every deploy (triggered by a deploy hook from your host or CI), and email you only when something changes. Nothing to install: you verify your domain once. What that means for Supabase keys:
- Free check, no sign-up: reads the JavaScript and source maps your site serves. A
service_roleJWT or ansb_secret_key is reported as a critical finding (shown masked, never stored), with a fix prompt. An anon JWT orsb_publishable_key is not a leak: it appears as one info note, "Public client keys found: meant to be public, not a leak", which also lists any Stripe publishable or Firebase web config keys. It does not test your database. - Deep scan, only after you verify you own the domain (DNS TXT record, a meta tag in your homepage's HTML, or a
/.well-known/latchdog.txtfile): using the public key it found, it sends oneSELECT ... LIMIT 1to each of up to 15 tables or views your site's code names with.from("..."), and records only whether a row came back. A readable one is reported as high, or critical when the name looks sensitive (users, orders, payments and the like). Row contents are discarded and never stored. - Plans: the Free plan includes the free check and one deep scan after verification. Starter is $19/month for up to 3 apps and Pro is $39/month for up to 10 apps; both deep-scan every day and after every deploy, and email you only when something is new, worse or fixed. A one-time $9 Full Report gives you one more full deep scan without subscribing, and the $9 comes off your first month if you subscribe within 30 days. Not happy? Email support@latchdog.com within 7 days of a payment and that payment is refunded in full.
- Limits: it can't see your policies, grants or functions (use Supabase's Security Advisor and the SQL above), it doesn't call
/rest/v1/rpc/functions, it won't test a table your code never names, and it can't check what one signed-in user can see of another's data. It's an outside-in check, not a penetration test or a guarantee.
Also useful alongside this: how to monitor a Supabase app for leaked keys and open RLS, is my Lovable app secure? and the vibe coding security checklist.
Sources
Read directly on 2026-10-29: API keys, Securing your data, Row Level Security, Securing your API, JWT signing keys, Migrating to new API keys, the JWT signing keys announcement. Supabase changes these pages, so check them if something here no longer matches your dashboard.
Keep watching your app, not just checking it once
LatchDog keeps watching your live app after launch. Starter ($19/month, up to 3 apps) and Pro ($39/month, up to 10 apps) re-scan it automatically every day and after every deploy (triggered by a deploy hook from your host or CI), and email you only when something changes. Nothing to install: you verify your domain once.
Start with the free check (headers, HTTPS, leaked keys in your JavaScript, source maps, .env/.git). Open-table checks run after you verify your domain. See plans
Free, read-only public check: no sign-up, no card. Headers, HTTPS, leaked keys in your JavaScript (always masked), public source maps and exposed .env/.git files. Supabase table checks run after you verify you own the domain.
Want the open-table check too? Get my free deep scan: sign in with your email (no password) and verify you own the domain. Your first deep scan is free and adds CORS, more of your JavaScript and, if your app uses Supabase, whether anyone can read its tables and views with your public key.