auditmyapp

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.

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 runsWhich key
React, Next.js client components, any browser codePublishable
Mobile appsPublishable
Supabase Edge FunctionsSecret
Your own server, API routes, cron jobsSecret
Automation tools such as n8n or MakeSecret, stored in the tool's credential store
Anything committed to gitNeither

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