Supabase API keys: publishable, secret, anon and service_role
The publishable key (sb_publishable_…) is the new anon key: same low privileges, same Row Level Security behavior, safe in your client bundle. A secret key (sb_secret_…) replaces service_role: it runs as the same BYPASSRLS role, so it never leaves your server. And yes, service_role is deprecated. Supabase's docs say it is "deprecating the anon and service_role keys by the end of 2026."
Both key sets work side by side until you switch the legacy pair off, so you can migrate one client at a time. That swap is also when a secret key most often lands in the wrong variable, usually while someone is chasing an Invalid API key error.
Supabase API keys at a glance: which key goes where?
Use the publishable key anywhere your code is visible to users, and a secret key only in code that runs on a machine you control.
Publishable (sb_publishable_…) | Secret (sb_secret_…) | |
|---|---|---|
| Replaces | anon | service_role |
| The old key looked like | a long eyJ… JWT | a long eyJ… JWT |
| Safe in the browser? | Yes, by design | Never |
| Respects RLS? | Yes | No, BYPASSRLS |
| Where it belongs | Client components, mobile apps, CLIs | Route handlers, server actions, cron jobs, Edge Functions |
| Blast radius if leaked | Bounded by your policies | Your entire database |
The mapping is one-to-one, so the mental model you already have still holds: the anon key is safe to expose because RLS stands between it and your data, and the service_role key is catastrophic in a browser because nothing does.
To tell them apart at a glance: the new keys are short strings with an sb_publishable_ or sb_secret_ prefix. The legacy keys are long JWTs that start with eyJ. If a tutorial or AI assistant hands you an eyJ… key, it was written for the old system.
Is the service_role key deprecated?
Yes. Supabase's API keys docs and its migration guide both carry the same notice: "Supabase is deprecating the anon and service_role keys by the end of 2026." Deprecated is not the same as switched off:
- Your legacy keys keep working until you deactivate them. Creating publishable and secret keys doesn't revoke
anonorservice_role; turning them off is a separate step in Settings > API Keys. - Removal has no firm date. Supabase's announcement, discussion #29260, lists "Late 2026, TBC" for when legacy keys "will be deleted and removed from the Docs / Dashboard", and warns that by then "You have to migrate to use the new API keys … or your app will break."
- Restored projects may not get them back. The same timetable says "Projects restored from 1st November 2025 will no longer be restored with the legacy API keys," and warns that paused projects that are restored "risk being broken." If you restore a paused app, plan to switch its clients to the new keys.
If your app already fails with Legacy API keys are disabled, someone switched the old pair off while a client still uses it. That error has its own fix.
What the new keys actually improve
Three things, and the first is the reason to migrate rather than wait:
1. Independent rotation. You can create several secret keys and name them, one per backend component. Supabase's advice: "If one component leaks its key, you only rotate that one." Under service_role you had exactly one key, so any leak meant rotating the key every backend shared.
2. A guardrail against browser use. Per Supabase's migration guide, secret keys "return HTTP 401 if used in a browser (matched on the User-Agent header)." A secret key pasted into a client bundle gets rejected when a normal browser sends it, with the message Forbidden use of secret API key in browser. Server scripts that send a browser-style User-Agent get the same rejection.
Be clear-eyed about what that is. User-Agent is a string the client chooses. Supabase's own docs say a leaked key should be deleted immediately because "an attacker can still use the key from other tools." A secret key in your client bundle is still a full compromise. Treat the 401 as a seatbelt, not a lock.
3. Not JWTs. The legacy keys were JWTs signed with your project's shared JWT secret. The new keys are short opaque strings, so API key management no longer touches that secret. Your users' access tokens are still signed by it, though, until you also move to JWT signing keys, which is a separate migration.
Where to find your publishable and secret keys
- Dashboard: the project's Connect dialog shows the URL and publishable key for your framework. Settings > API Keys holds every key, legacy or not; the new ones are on the Publishable and secret API keys tab. If you see a Create new API keys button, the project is still on legacy keys only. Creating them is safe and leaves
anonandservice_roleworking. - CLI:
supabase projects api-keys --project-ref <your-project-ref>. - Local stack:
supabase startprints a publishable and a secret key, andsupabase statusprints them again. They are unrelated to your hosted project's keys.
Is the publishable key safe to commit to git?
Yes. It's designed to be public and it ends up in your client bundle anyway. Supabase lists it as safe for web pages, mobile and desktop apps, GitHub Actions, CLIs and source code.
That safety rests on one condition: RLS must be enabled, with real policies, on every table exposed to the Data API. A publishable key against a table with RLS disabled is an open door, and so is a policy that's enabled but written as USING (true). The key isn't what protects you. Your policies are.
The mistake to watch for during migration
Here's the one that turns a routine key swap into an incident. Someone moves a client to the new keys, hits a permissions error because RLS is doing its job, and "fixes" it by reaching for the secret key with the prefix that was already there:
import { createClient } from "@supabase/supabase-js";
// WRONG: a secret key behind NEXT_PUBLIC_ is inlined into the browser bundle.
export const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SECRET_KEY!
);
NEXT_PUBLIC_ is not a naming convention. It's an instruction to Next.js to inline the value into the client bundle at build time. This compiles, deploys, and hands a BYPASSRLS credential to every visitor who opens devtools.
- Criticallib/supabase/admin.ts:6
Service role key exposed to the client
Never prefix the service role key with NEXT_PUBLIC_. Read it only in server code via process.env.SUPABASE_SERVICE_ROLE_KEY, and rotate the key immediately since it has been exposed. - Criticallib/supabase/admin.ts:6
Secret exposed through NEXT_PUBLIC_
Drop the NEXT_PUBLIC_ prefix and read the value only on the server. Publishable/anon keys are fine to expose; secret keys, tokens, and passwords are not — rotate any that have shipped.
The correct setup
Two variables, two clients, and a hard boundary between them:
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_... # no NEXT_PUBLIC_ prefix, ever
// lib/supabase/admin.ts — server only
import "server-only";
import { createClient } from "@supabase/supabase-js";
export const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false } }
);
The server-only package turns an accidental import from a client component into a build-time error. It's one line, and it converts a silent breach into a failed build. Add it to any module that touches a secret.
Migrating an existing project
Supabase's guide is a six-step sequence, and the order matters because both key sets stay live throughout:
- Create publishable and secret keys in the dashboard. This does not affect your existing keys.
- Replace
anonwith the publishable key in client code. - Replace
service_rolewith a secret key in backend code. - Update Edge Functions to read the new keys from their environment.
- Verify nothing still uses the legacy keys.
- Deactivate the legacy keys.
Don't rush step 5. Supabase notes "There's no automatic usage indicator, so this is a manual check." The usual stragglers are app versions already on users' phones, CI scripts, third-party integrations, cron jobs, and Database Webhooks. Step 6 is reversible, but every forgotten caller fails until you notice.
Three places the swap breaks quietly:
- Edge Functions. Read
SUPABASE_SECRET_KEYSorSUPABASE_PUBLISHABLE_KEYS, JSON objects keyed by name (the keys the migration creates are nameddefault).SUPABASE_SERVICE_ROLE_KEYandSUPABASE_ANON_KEYstill exist, but they carry the legacy keys. Theverify_jwtcheck doesn't authenticate a caller that sends only an API key, so authorize in code. - Database Webhooks and
pg_net. These usually sendservice_roleasAuthorization: Bearer. Secret keys aren't JWTs and are rejected there; send them in theapikeyheader instead. - Realtime. Public connections (no signed-in user) last at most 24 hours.
A 30-second self-check
# 1. Any secret key behind a public prefix?
grep -rn "NEXT_PUBLIC_.*SECRET\|NEXT_PUBLIC_.*SERVICE_ROLE" .
# 2. Any raw secret key literal committed?
grep -rn "sb_secret_" --exclude-dir=node_modules .
# 3. Does the built bundle contain one?
grep -r "sb_secret_" .next/ 2>/dev/null
# 4. Anything still on the legacy keys? (step 5)
grep -rn "ANON_KEY\|SERVICE_ROLE_KEY" --exclude-dir=node_modules --exclude-dir=.next .
A hit on 1 to 3 means rotate first, then fix the code. Deleting the line doesn't remove it from git history. Hits on 4 are your step-5 checklist. GuardLayer flags secret keys behind NEXT_PUBLIC_ and committed sb_secret_ literals on every push, and it knows the difference: PUBLISHABLE and ANON in a public variable name are expected and stay quiet.
FAQ
Is the Supabase service_role key deprecated?
Yes. Supabase says it is deprecating anon and service_role by the end of 2026, and its announcement lists removal as "Late 2026, TBC". Until you deactivate them, the legacy keys keep working, so migrate now while both sets run side by side.
Is the publishable key the same thing as the anon key?
Functionally, yes. It resolves to the same anon Postgres role (or authenticated once a user signs in), so your policies behave identically. The differences are the format (a short string, not a JWT) and how keys are managed.
Where do I find my Supabase secret key? In Settings > API Keys, on the Publishable and secret API keys tab. If the project only shows a Create new API keys button, it has none yet. Creating them doesn't affect your legacy keys.
Can a secret key be used from the browser?
It's blocked by a User-Agent check that returns HTTP 401, but that check is a mitigation, not a boundary. Anyone holding the key can send any User-Agent from outside a browser. A leaked secret key must be deleted, not tolerated.
Do secret keys bypass RLS?
Yes. They act as the built-in service_role Postgres role, which has the BYPASSRLS attribute and skips every policy, exactly like the legacy service_role key. That's why they belong only in server code.
Can I have more than one secret key? Yes, and it's a main advantage. Create one per backend service so a single leak forces one rotation instead of all of them.
See which of your Supabase tables anyone can read, free
Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.
Check my Supabase projectPrefer to check code instead? Scan your repo, no signup.