Notes / Supabase keys
Publishable or secret: which Supabase key belongs where
One key is designed to be public. The other ignores every security rule you have written. Here is where each belongs, and a test that shows you the difference.
Supabase gives you two kinds of key, and almost every serious leak I see comes from putting the second one where the first belongs.
- The publishable key starts with
sb_publishable_. Older projects call it the anon key. It is meant to be public, and every request it makes is filtered by your row-level security policies. - The secret key starts with
sb_secret_. Older projects call it the service_role key. It bypasses row-level security completely. Policies do not apply to it at all.
Supabase is retiring the older anon and service_role keys by the end of 2026, so depending on when your project was created you may have either pair, or both.
Where each one belongs
The rule is simple: if the code can be read by whoever runs it, the key in it is public. That covers anything in a browser or a mobile app, however well it is minified.
| Where the code runs | Which key |
|---|---|
| React, Next.js client components, any browser code | Publishable |
| Mobile apps | Publishable |
| Supabase Edge Functions | Secret |
| Your own server, API routes, cron jobs | Secret |
| Automation tools such as n8n or Make | Secret, stored in the tool's credential store |
| Anything committed to git | Neither |
A two-minute test on your own project
This shows you what the difference actually means. Run both in your terminal, never in a browser console or a web page, and use a table that holds per-user data.
# As a visitor: row-level security applies
curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/notes?select=id&limit=5" \
-H "apikey: YOUR_PUBLISHABLE_KEY"
# As your server: every policy is ignored
curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/notes?select=id&limit=5" \
-H "apikey: YOUR_SECRET_KEY" \
-H "Authorization: Bearer YOUR_SECRET_KEY"The healthy result is an empty list from the first command and rows from the second. That means your policies are doing their job: a signed-out visitor sees nothing, while your server sees everything.
If the first command returns rows from a table that should be private, stop and run the self-check. Anyone with your public key, which is everyone, can read that table.
"But I need the secret key in the browser"
You never do. What you need is a small piece of server code that checks who is asking, then does the privileged part on their behalf. In a Supabase Edge Function, that looks like this:
// Who is calling? Ask with the caller's own session, not the secret key.
const userClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('PUBLISHABLE_KEY')!,
{ global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } } },
);
const { data: { user } } = await userClient.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
// Only now, and only for what this user is allowed to do
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SERVER_SECRET_KEY')!, // set with: supabase secrets set SERVER_SECRET_KEY=...
);The important part is the order. Establish who is calling first, using their session. Only then reach for the key that ignores your policies, and only for the specific thing that needs it.
Rotating a key
With the new keys you can hold several secret keys at once, which makes rotation painless: create a second one in Settings > API Keys, switch your server over, confirm it works, then delete the old one. No downtime.
If you are still on the legacy service_role key, Supabase now points you towards migrating to the new keys and switching the legacy ones off rather than rotating them. That is worth doing before the 2026 deadline regardless.
Before you ship
- Check that
.envis in your.gitignore, and that no key was committed earlier in the project's history. - Remember that anything prefixed
NEXT_PUBLIC_orVITE_is compiled into the browser bundle, whatever you named it. - Search your live site for a secret key before launch. It takes five minutes and it is the single highest-value check on the list.