Supabase publishable vs secret keys, explained
The publishable key (sb_publishable_…) replaces the anon key: it's safe to ship in your client bundle because Row Level Security still gates every query. The secret key (sb_secret_…) replaces service_role: it carries the Postgres BYPASSRLS attribute and skips every policy, so it must never leave your server. Same split as before — new names, better ergonomics.
Supabase is moving off the legacy JWT-based anon and service_role keys. Both new key types work alongside the old ones today, and Supabase's documentation states the legacy keys "will be deprecated by the end of 2026." If you're starting a project now, start on the new keys. If you have an existing app, the migration is mechanical — but it's also the moment when a secret key gets pasted into the wrong variable.
Which key goes where?
Use the publishable key anywhere your code is visible to users, and the secret key only in code that runs on a machine you control.
Publishable (sb_publishable_…) | Secret (sb_secret_…) | |
|---|---|---|
| Replaces | anon | service_role |
| 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 with the old keys, which means the mental model you already have transfers intact: the anon key is safe to expose precisely because RLS stands between it and your data, and the service_role key is catastrophic in a browser precisely because nothing does.
What the new keys actually improve
Three things, and the first one is the reason to migrate rather than wait:
1. Independent rotation. You can create multiple secret keys and name them — one per backend component. When one leaks, you revoke that key and rotate that one service. 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)." That's a genuinely useful backstop — a leaked secret key used from a normal browser session gets rejected.
Be clear-eyed about what that is, though: User-Agent is a client-supplied string that any attacker can set to anything. It stops accidents and casual abuse from a real browser; it does not stop someone who has your secret key and a curl command. A secret key in your client bundle is still a full compromise. Treat the 401 as a seatbelt, not a lock.
3. Opaque, non-JWT format. The legacy keys were JWTs signed with your project's shared secret. The new keys are opaque strings, which decouples API key management from JWT signing.
Is the publishable key safe to commit to git?
Yes — it's designed to be public, and it appears in your client bundle anyway. It's listed as safe for web pages, mobile and desktop apps, CI actions, and source code.
But that safety rests entirely 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 technically 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 updates 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 — worth adding to any module that touches a secret.
Migrating an existing project
Supabase's guide is a six-step sequence, and the ordering 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 skip step 5. Deactivating a legacy key that a forgotten cron job still uses is a self-inflicted outage.
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
Any hit on any of those means rotate first, then fix the code — and remember that deleting the line doesn't remove it from git history. GuardLayer flags secret keys behind NEXT_PUBLIC_ on every push, and it knows the difference: PUBLISHABLE and ANON in a public variable name are expected and stay quiet.
FAQ
Do I have to migrate to the new API keys?
Not immediately — both work today. But Supabase's documentation says the legacy anon and service_role keys will be deprecated by the end of 2026, so new projects should start on the new keys and existing ones should plan the swap.
Is the publishable key the same thing as the anon key? Functionally, yes: same low privileges, same RLS behavior, so your policies work identically. The differences are the format (opaque, not a JWT) and the management story.
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 they like from outside a browser. A leaked secret key must be revoked, not tolerated.
Do secret keys bypass RLS?
Yes. They carry the Postgres BYPASSRLS attribute and skip every policy, exactly like service_role did. That's why they belong only in server code.
Can I have more than one secret key? Yes — that's a main advantage. Create one per backend service so a single leak forces one rotation instead of all of them.
Catch this before it ships — free
GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.