Skip to content
shipanysaas

Supabase anon key vs service role key (and the new publishable and secret keys)

The Supabase anon key is safe in a browser; the service role key bypasses RLS and stays on the server. What the new publishable and secret keys change.

The Supabase anon key is public: it is safe in a browser or phone app, because row-level security decides what it can reach. The Supabase service role key bypasses RLS completely, so it belongs only on your server. Supabase is replacing both with publishable (sb_publishable_…) and secret (sb_secret_…) keys, and deprecating the old pair by the end of 2026.

The first half of that holds only if every table your API exposes has RLS turned on. Everything below is checked against Supabase's API keys guide and related docs as of October 2026.

The four Supabase API keys at a glance

KeyLooks likeRuns asBypasses RLSSafe in a browser or appStatus
Publishable keysb_publishable_...anon, or authenticated once a user signs inNoYesCurrent
Secret keysb_secret_...service_roleYesNoCurrent
Legacy anon keyA long-lived JWTanon, or authenticated once a user signs inNoYesBeing deprecated by the end of 2026
Legacy service_role keyA long-lived JWTservice_roleYesNoBeing deprecated by the end of 2026

The publishable key replaces the anon key, and the secret key replaces the service role key. The roles and the rules stay the same; how the keys work underneath is what changed.

What is the Supabase anon key?

The anon key is the public API key you put in client code. It identifies your project, not a person: requests made with it run as the anon Postgres role until someone signs in, then as authenticated, and your RLS policies decide what each role may read or change.

The legacy anon key is a JWT signed by your project's legacy JWT secret. Its replacement, the publishable key, is not a JWT, but it maps to the same roles, so your policies behave the same after you switch.

When is the Supabase anon key not safe?

The anon key is unsafe when a table your API exposes has no row-level security. Supabase's RLS guide warns that such a table "is readable and writable by any role with a grant on it", and anyone who opens your site has your key.

Supabase also lists what a publishable key does not protect against: reverse engineering, someone inspecting your network traffic, cross-site request forgery, cross-site scripting, phishing and man-in-the-middle attacks. The key is a project identifier. Your policies are the lock.

Before you ship a public key:

  1. Turn RLS on for every table in an exposed schema.
  2. Write policies for the anon and authenticated roles, one per command.
  3. Grant each role only the commands it needs.
  4. Run the Security Advisor, and read each finding before you dismiss it.
  5. Test the policies by signing in as the wrong user and trying to read someone else's rows.

Supabase RLS explained walks through all five with the SQL.

What is the Supabase service role key?

The service role key is the server-side admin key. It runs as the service_role Postgres role, which has the BYPASSRLS attribute, so no policy applies to it. It is also the key for the Auth admin API: Supabase's reference says any method under supabase.auth.admin "requires a secret key".

You need it for work no single user is allowed to do: a payment webhook writing subscription rows, sending a team invitation, an admin panel, a nightly job. Because the database checks nothing, the code around this key has to check permissions itself.

Does the service role key always bypass RLS?

No, only when the request carries no user access token. Supabase's RLS guide says "a secret key bypasses RLS only when the request carries no user access token", so a server client that picks up a signed-in user's session runs under that user's policies instead.

Supabase's troubleshooting guide names the usual ways this happens:

  • An SSR client. Clients built for server rendering read the user's session from cookies, and that session replaces the key.
  • A hand-set Authorization header carrying a user's JWT.
  • Calling signUp or a sign-in method on the admin client, which stores the returned session on that client.

The fix is a separate client made with supabase-js directly, with sessions turned off, and auth.admin.createUser() instead of signUp:

import 'server-only';
import { createClient } from '@supabase/supabase-js';

export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SECRET_KEY!,
  {
    auth: {
      persistSession: false,
      autoRefreshToken: false,
      detectSessionInUrl: false,
    },
  },
);

What are Supabase's new publishable and secret keys?

They take the legacy keys' places with the same roles: the publishable key (sb_publishable_…) goes in client code, and the secret key (sb_secret_…) goes on servers. What changes is how they work.

  • They aren't JWTs. Send them in the apikey header only. If one also arrives as Authorization: Bearer, the platform tries to parse it as a JWT and rejects the request with Invalid JWT.
  • They no longer depend on the JWT secret. The legacy keys are JWTs signed by your project's legacy JWT secret. The new ones "no longer touch your project's JWT secret", so changing a key and changing token signing are separate jobs.
  • You can have several secret keys. Supabase advises one per backend component, so a leak means replacing one key, not every server's.
  • Secret keys refuse browsers. Supabase checks the User-Agent header and answers 401 Unauthorized when a secret key comes from a browser. A leaked key still works from other tools, so treat this as a safety net.
  • Edge Functions need a code change. The verify_jwt check accepts the new keys, but passing it "doesn't authenticate the caller", so authorize requests in your function. The keys arrive as JSON objects keyed by name:
const secretKey = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)['default'];

Do I have to migrate from the anon and service role keys?

Yes, before the end of 2026. Supabase says it is "deprecating the anon and service_role keys by the end of 2026", and creating the new keys doesn't revoke the old ones, so you can switch at your own pace and turn the legacy keys off last.

Supabase's migration guide gives this order:

  1. Create the new keys in Settings > API Keys. They are named default.
  2. Swap the publishable key into client code: web pages, phone apps, CLIs.
  3. Swap the secret key into backend code. For Database Webhooks and pg_net calls, move it from the Authorization header to apikey, and keep it in Vault.
  4. Update Edge Functions to read the new environment variables.
  5. Check that nothing still uses the legacy keys: CI, cron jobs, webhooks, third-party integrations, and the app versions already installed on people's phones.
  6. Deactivate the legacy keys in Settings > API Keys. You can reactivate them if you find a client you missed.

Step 5 matters most for a phone app. An installed build carries whatever key it shipped with, and some people never update. Ship the publishable key in an app update first, give it time to spread, then deactivate the anon key.

What happens if the Supabase service role key leaks?

Whoever holds it can read, change and delete every row your API exposes, ignoring all your policies, and can create, change or delete users through the Auth admin API. Treat it as a breach: replace the key first, then work out what was touched.

Look quickly. API and database logs are kept for 1 day on Free and 7 days on Pro. And if the key was committed to git, deleting the file doesn't help: it stays in the history, so the key has to be replaced.

How do I rotate a leaked Supabase secret key?

Create a new secret key, put it everywhere the old one was, confirm nothing uses the old one, then delete it. Supabase's steps:

  1. Fix whatever leaked it: a committed .env file, a log line, a variable with a public prefix.
  2. Create a new secret key in Settings > API Keys.
  3. Replace the leaked key everywhere: your host's environment settings, CI, Edge Functions, webhooks.
  4. Confirm every component uses the new key.
  5. Delete the old key. Deleting a secret key can't be undone, so this comes last.

Supabase also says to delete a leaked key immediately. With one secret key per component, doing that takes down only the component that leaked.

A legacy service_role key works differently. It is a JWT signed by your legacy JWT secret, so the fix is to move each server to a new secret key and then deactivate the legacy keys. Revoking the legacy JWT secret itself means your project stops trusting every JWT signed with it, which is why Supabase makes you disable anon and service_role first.

Which Supabase key goes where in a Next.js and Expo app?

The publishable key goes in the browser and the phone app; the secret key goes only in server code that never ships to a client. Next.js includes only NEXT_PUBLIC_ variables in the browser bundle, and Expo writes every EXPO_PUBLIC_ variable into the app, where Expo's environment variables guide says they are "visible in plain-text in your compiled application".

Where the code runsKeyVariable in shipanysaas
BrowserPublishableNEXT_PUBLIC_SUPABASE_PUBLIC_KEY
Expo phone appPublishableEXPO_PUBLIC_SUPABASE_PUBLIC_KEY
Next.js server code acting for a userPublishable, plus the user's sessionNEXT_PUBLIC_SUPABASE_PUBLIC_KEY
Webhooks, invitations, admin panelSecretSUPABASE_SECRET_KEY

This is how shipanysaas does it. The kit uses SUPABASE_SECRET_KEY in three files, and each starts with import 'server-only', so Next.js fails the build if a client component imports one. The only database client that uses it is getSupabaseServerAdminClient() in packages/supabase/src/clients/server-admin-client.ts, a separate supabase-js client with sessions turned off, as Supabase recommends. The phone app has no reference to the secret key at all. pnpm env:check fails if a committed env file holds a real secret key or a legacy service_role JWT, or sets a NEXT_PUBLIC_ or EXPO_PUBLIC_ variable named like a secret. Everywhere else, RLS does the work: it's on for all 13 tables, with pgTAP tests. See Row-level security.

One side effect worth knowing: the kit derives the key that signs invitation links from SUPABASE_SECRET_KEY. After a rotation, links already emailed stop working as one-click links, and invitees go through the normal sign-in, then accept with the matching email.

If you manage your project through an agent, connecting the Supabase MCP server to Claude Code covers keeping it read-only and away from production. For what each plan costs, see Supabase pricing in 2026.

FAQ

Does the service role key bypass RLS?

Yes. It runs as the service_role role, which has the BYPASSRLS attribute, so no policy applies. The exception: if the request also carries a signed-in user's access token, for example from an SSR client reading cookies, it runs under that user's policies. The new secret key behaves the same way.

Is it safe to expose the Supabase anon key?

Yes, as long as every table in an exposed schema has RLS turned on with policies you've tested. The anon key and its replacement, the publishable key, are meant for browsers and phone apps. Without RLS, anyone with the key can read and write that table.

Where do I find my Supabase service role key?

In the Supabase dashboard, under your project's Settings > API Keys, which holds every key, legacy or new. The Connect dialog shows the project URL and publishable key for your framework. For new code, create and use a secret key (sb_secret_…) rather than the legacy service role key.

Is the publishable key the same as the anon key?

It does the same job with the same roles, but it isn't a JWT and doesn't depend on your JWT secret. You can run both while you migrate, then deactivate the anon key.

Live demo

Resets hourly

Get the kit