In short
- The publishable key and the anon key both give the
anonrole. Switching changes nothing about what a stranger can read: grants and RLS still decide. - The secret key (
sb_secret_...) replaces the service_role key and still bypasses RLS. Supabase refuses it from browsers by reading theUser-Agentheader, which is a safety net, not a reason to ship it anywhere public. - Creating the new keys does not turn off the old ones. Until you deactivate the legacy keys in Settings > API Keys, your current anon and service_role keys keep working in every build, repository and log they reached.
- Edge Functions called with the new keys need
verify_jwt = false. Turn it off only with an authorization check in the handler, or the function answers anyone. - If a service_role key leaked, deactivating the legacy keys is not the last step: revoke the legacy JWT secret too, through the JWT signing keys migration.
On this page
- What is the difference between the anon key and the publishable key?
- Why Supabase introduced new API keys
- What stays the same: the key is public and RLS protects the data
- How to tell which keys your app uses
- How to migrate from the anon key to the publishable key
- The traps after the switch
- How rotation works with each key
- What Keybrum checks, whichever key you use
- Questions
- Sources
What is the difference between the anon key and the publishable key?
Both are the public key your frontend sends with every request to Supabase. Both tell Supabase which project you mean, and both make Postgres treat the caller as the anon role until a user signs in. Supabase's migration guide puts it in one line: the publishable key carries the same low privileges as the anon key, so your RLS policies behave the same.
The differences are in the key itself. The anon key is a long-lived JSON Web Token (JWT), a long string starting with eyJ, signed with your project's legacy JWT secret. The publishable key is a short string starting with sb_publishable_. It is not a JWT and it is not signed with that secret, so you can manage it without touching your other keys. Supabase is deprecating the anon key and its server-side twin, the service_role key, by the end of 2026. On the server side, the secret key (sb_secret_...) replaces the service_role key in the same way.
| Key | Format | Postgres role | Where it belongs | If it leaks |
|---|---|---|---|---|
| Publishable key | sb_publishable_... | anon | Browser, mobile and desktop apps, CLIs, public code | Nothing new: it is public by design. RLS decides what it reaches. |
| Anon key (legacy) | JWT starting with eyJ, long-lived | anon | The same places, until you migrate | Same as the publishable key. |
| Secret key | sb_secret_..., several per project | service_role, bypasses RLS | Servers, Edge Functions, cron jobs | Full access to your data. Deleting the key revokes it at once. |
| Service role key (legacy) | JWT starting with eyJ, long-lived | service_role, bypasses RLS | Your server only, until you migrate | Full access. Retiring it turns off the anon key too, and calls for revoking the legacy JWT secret. |
authenticated. Not sure which key your code holds? Paste it in the Supabase key checker.Why Supabase introduced new API keys
When Supabase announced the change in September 2024, the reasons it listed were about managing keys, not about what the anon role can read:
- One secret behind everything. The anon key, the service_role key and every user's session token were all tied to the same JWT secret, so the low-privilege key and the admin key could not be changed apart.
- Rotation meant downtime. Each part of the keys could not be rotated on its own without downtime, and a JWT secret rotation that went wrong could not be rolled back.
- Mobile apps make it worse. App store review can take days or weeks, so a forced rotation can leave mobile users broken for weeks, and people keep running old desktop, CLI and mobile versions.
- Long-lived tokens. The legacy keys were JWTs with a 10-year expiry.
The new keys add protections the old ones lacked. Secret keys are hidden in the dashboard until you reveal one, and each reveal is recorded in your organization's audit log. Deleting a secret key revokes it instantly. You can create one secret key per backend component, so a single leak forces a single rotation.
What stays the same: the key is public and RLS protects the data
Moving to the publishable key does not make one table safer. If a table in public has RLS off and the anon role holds grants on it, a stranger reads it with your publishable key exactly as they did with your anon key. Supabase lists the publishable key as safe to expose in web pages, mobile apps, CLIs and source code, and says it does not protect against code analysis or the browser's network inspector.
Sign-in does not change either. Users still sign in through Supabase Auth and get their own JWT, and policies that compare a column to auth.uid() work as before. Supabase also scheduled one related change for 2026: the root of the Data API (/rest/v1/) stops returning your schema to the anon key, for new projects from 11 March and for all projects from 8 April, and answers that only a secret API key may read it. Your table names are still in your JavaScript, though, in every supabase.from('orders') call.
How to tell which keys your app uses
Prefixes tell you most of it. sb_publishable_ and sb_secret_ are the new keys. A long string starting with eyJ is a legacy JWT, anon or service_role, and only its decoded payload says which. Supabase warns that if a tool, a tutorial or an AI assistant tells you to copy a long key starting with eyJ, it was written for the legacy keys.
# In your repository: every place that still reads a legacy key
grep -rnE "SUPABASE_ANON_KEY|SERVICE_ROLE_KEY|eyJhbGciOi" . \
--exclude-dir=node_modules --exclude-dir=.git
# In your built frontend: a secret key must never appear here
grep -rlE "sb_secret_[A-Za-z0-9_-]{20,}" dist .next/static 2>/dev/nulleyJ key into the key checker to see anon or service_role.In the dashboard, open Settings > API Keys: every key lives there, legacy or not. The new keys are on the Publishable and secret API keys tab. If that tab shows a Create new API keys button, your project is still on legacy keys only.
How to migrate from the anon key to the publishable key
Both key systems work at the same time, so you can move one client at a time and turn the old keys off only at the end. This is the order Supabase's migration guide follows, with the parts that break called out.
- 1
Create the new keys
In Settings > API Keys, on the Publishable and secret API keys tab, click Create new API keys. This adds a publishable key and a secret key, both named
default, next to your anon and service_role keys. Nothing stops working. - 2
Swap the publishable key in client code
Everywhere the anon key ships: web pages, mobile and desktop apps, CLIs. Supabase's examples read it from
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; use your framework's public prefix otherwise. Pass it tocreateClientlike the anon key: the client libraries need no other change. - 3
Swap the secret key in backend code
Servers, workers, cron jobs. Give it a name without a public prefix (
SUPABASE_SECRET_KEY), or your bundler ships it. You can add one named secret key per component later, so one leak means one rotation. - 4
Move keys off the Authorization header
The new keys are not JWTs: send them on the
apikeyheader. Database Webhooks andpg_netcalls usually send the service_role key asAuthorization: Bearer, where the new secret key is rejected because it is not a JWT. Move it toapikey, and read it from Vault instead of writing it into SQL in plain text. - 5
Update Edge Functions
Supabase injects
SUPABASE_PUBLISHABLE_KEYSandSUPABASE_SECRET_KEYSas JSON objects keyed by name (default); check they appear in Edge Functions > Secrets. The oldSUPABASE_ANON_KEYandSUPABASE_SERVICE_ROLE_KEYstill hold the legacy keys. The platform'sverify_jwtcheck only understands legacy keys, so setverify_jwt = falsefor functions called with the new ones and authorize the request in code (see the traps below). - 6
Find the callers you forgot
Supabase has no usage indicator for the legacy keys, so this is a manual search. Check app versions already on users' phones, CI/CD pipelines, deployment scripts, third-party integrations, webhooks, cron jobs and
pg_netcalls. - 7
Deactivate the legacy keys
Back in Settings > API Keys. The step is reversible if a client you missed breaks, unless a legacy key leaked: then move that client to a new key instead (see rotation below). Run the check below on your own project.
- 8
Move to JWT signing keys
A separate migration. The new API keys no longer touch the JWT secret, but your users' access tokens are still signed by it until you switch to signing keys. Rotating the legacy secret signs every active user out; rotating a signing key signs nobody out.
# Your own project: replace the ref, the table and the keys
URL="https://<project-ref>.supabase.co/rest/v1/<table>?select=*&limit=1"
# The old anon key: should now be refused
curl -s -o /dev/null -w "%{http_code}\n" "$URL" -H "apikey: <old anon key>"
# The publishable key: same status as before the switch
curl -s -o /dev/null -w "%{http_code}\n" "$URL" -H "apikey: sb_publishable_..."If your code calls the REST API with fetch instead of supabase-js, check the headers. Many hand-written calls copy the anon key into Authorization as well, which made sense for a JWT and does not for the new key.
// Before: the legacy anon key, sent on both headers
await fetch(`${SUPABASE_URL}/rest/v1/todos?select=*`, {
headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}` },
});
// After: the publishable key on apikey only.
// Authorization carries the signed-in user's own token, or nothing.
await fetch(`${SUPABASE_URL}/rest/v1/todos?select=*`, {
headers: {
apikey: PUBLISHABLE_KEY,
...(session ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
});Authorization is accepted only when it matches apikey exactly, for backward compatibility, and its migration guide warns that Edge Functions can reject it with Invalid JWT. Do not rely on it.The traps after the switch
The old keys keep working until you turn them off
Creating publishable and secret keys revokes nothing. Every old build that carries your current anon key, and every repository, chat or CI log where your service_role key was pasted, still opens your project until the legacy keys are deactivated. If a leak is why you are migrating, the migration protects you only from that step on.
verify_jwt = false with nothing behind it
To accept the new keys, an Edge Function needs the platform's JWT check off and an authorization check in its own code. The second half is easy to skip when a coding agent is told to fix a 401. Supabase's announcement warned that with that option off, a function "can be called without knowing any API key". If the function calls OpenAI or Stripe, anyone with its URL spends your money. The @supabase/server SDK does the check for you:
// supabase/functions/summarize/index.ts
import { withSupabase } from 'npm:@supabase/server@1'
export default {
// 'user': only a caller with a valid session token gets in
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
// ctx.supabase respects RLS for this user
return Response.json({ email: ctx.userClaims?.email })
}),
}# supabase/config.toml
[functions.summarize]
verify_jwt = falseauth: 'user' for functions your users call and auth: 'secret' for calls from your own backend or pg_net. More in unprotected AI endpoints.The legacy JWT secret is still there
Deactivating the legacy keys stops them as API keys. The legacy JWT secret that signed them still signs your users' sessions until you move to JWT signing keys, and anything that still trusts the secret can accept a leaked legacy key. Anyone who knows that secret can also sign a token with the service_role role. Revoking the secret requires the anon and service_role keys to be disabled first, since both are JWTs signed with it. On a self-hosted project, check that you replaced the example keys from .env.example: Supabase says never to start with those defaults.
Realtime and restored projects
Public Realtime connections made with the new keys last 24 hours at most, unless the user signs in through Supabase Auth or a supported third-party auth provider. And Supabase's timetable says projects restored from 1 November 2025 no longer get the legacy keys back, so an old paused project that still ships the anon key can break when you restore it.
How rotation works with each key
| Key | When to rotate | How |
|---|---|---|
| Publishable key | Rarely. It is public, and a new one does nothing for an open table. | Managed on its own, apart from the JWT secret and the secret keys. Ship a new build after any change. |
| Secret key | It leaked, or someone who held it should no longer have it. | Create a new secret key, move every component to it, then delete the old one. Deletion revokes it instantly and cannot be undone. |
| Anon key (legacy) | You finished migrating, or the service_role key or JWT secret leaked. | No rotation on its own: deactivate the legacy keys, which turns off anon and service_role together. |
| Service role key (legacy) | It leaked. | Create a secret key, replace service_role on servers and anon with a publishable key in clients, deactivate the legacy keys, then revoke the legacy JWT secret through signing keys. |
From the same guide: fix the cause of the leak before you rotate, or the new key leaks the same way. And if you find a client you missed after deactivating the legacy keys, move it to a new key instead of re-enabling them, because re-enabling brings back the compromised key. For the rest of the incident, see the leaked API key guide.
What Keybrum checks, whichever key you use
The free check reads your app's JavaScript the way a visitor's browser does. In about ten seconds it shows the backend, the keys exposed in the bundle and the tables your code names, with no account and nothing sent to your backend.
A full attack goes further. It looks for sb_secret_ keys and service_role JWTs in your bundle and checks whether they open the admin API. It reads and writes the tables, functions and buckets the anon role can reach, then does the same as a freshly signed-up account. When your public key is still a legacy JWT, it also checks offline whether its signature matches a well-known default or weak secret, a test that does not apply to publishable keys. Every finding comes with the request that worked, and the attack runs again after your fix.
Questions
What is the Supabase publishable key?
It is the public API key, starting with sb_publishable_, that replaces the legacy anon key in browsers, mobile apps and CLIs. Without a signed-in user it gives the anon Postgres role; with one, the user's own token makes it authenticated. Unlike the anon key, it is not a JWT.
Is the Supabase publishable key safe to expose?
Yes. Supabase lists it as safe to expose online, and it ships in your frontend anyway. It reaches only what your grants and RLS policies allow the anon and authenticated roles, so enable RLS on every table before you deploy.
What is the Supabase secret key?
The sb_secret_ key replaces the service_role key for servers, Edge Functions and background jobs. It maps to the service_role role, which bypasses RLS, so it must never appear in a browser, an app bundle or source control. You can create several and delete any one of them instantly.
sb_publishable vs anon key: does anything change for RLS?
No. Both give the anon role, so policies written for anon and authenticated behave exactly the same. The differences are the format, the independence from the JWT secret and the deprecation of the anon key.
Can I use the anon key and the publishable key at the same time?
Yes. Both key systems work side by side until you deactivate the legacy keys in Settings > API Keys. Supabase recommends migrating that way, one client at a time.
When do the Supabase legacy API keys stop working?
Supabase says it is deprecating the anon and service_role keys by the end of 2026. Its timetable lists their deletion as late 2026, still without a fixed day, and says projects restored from 1 November 2025 no longer get the legacy keys back. Migrate now rather than on the deadline.
Why does my Edge Function return Invalid JWT after switching keys?
The new keys are not JWTs. When one arrives on Authorization: Bearer, the platform tries to parse it as a JWT and rejects the request. Send the key on apikey only, set verify_jwt = false for that function, and authorize the request in your handler.
Sources
- 1.API keys, Supabase Docs
- 2.Migrating to publishable and secret API keys, Supabase Docs
- 3.Upcoming changes to Supabase API Keys (discussion #29260), Supabase on GitHub
- 4.Rotate a leaked or compromised API key, Supabase Docs
- 5.JWT Signing Keys, Supabase Docs
- 6.Breaking Change: Removing access to OpenAPI spec via the anon key, Supabase Changelog
- 7.Self-Hosting with Docker, Supabase Docs