Migrating to publishable and secret API keys
Changes to API keys
Supabase has changed the way keys work to improve project security and developer experience. You can read the full announcement on GitHub.
They will be deprecated by the end of 2026, and you should now use the publishable (sb_publishable_xxx) and secret (sb_secret_xxx) keys instead.
In most cases, you can get keys from the Project's Connect dialog, but if you want a specific key, you can find them in the Settings > API Keys section of the Dashboard.
- For new keys, open the API Keys tab, if you don't have a publishable key already, click Create new API Keys, and copy the value from the Publishable key section for client-side operations. For server-side operations, copy the value from the Secret keys section.
- For legacy keys, copy the
anonkey for client-side operations and theservice_rolekey for server-side operations from the Legacy API Keys tab.
This guide covers migrating an existing project. Both key types work simultaneously, so you can swap clients one at a time and deactivate the legacy keys only after nothing depends on them.
Before you start#
The migration maps onto your existing keys:
| Legacy key | Replace with | Used by |
|---|---|---|
anon | Publishable key | Browsers, mobile and desktop apps, CLIs, public source |
service_role | Secret key | Servers, Edge Functions, workers, other backend code |
For a full explanation of each key type, read the Understanding API keys guide.
Step 1: Create the new API keys#
Open the Settings > API Keys section of the Dashboard and select the Publishable and secret API keys tab.
Older projects don't have these keys yet. If you see a Create new API keys button, your project is still on legacy keys only. Creating the new keys is safe. It adds a publishable key and a secret key alongside your existing anon and service_role keys. Your legacy keys keep working.
The new keys are created under the name default. You can add more keys with different names later, for example, one secret key per backend component, so you can rotate them independently. For an initial migration, the default publishable and secret keys are all you need.
Step 2: Swap the publishable key in client code#
Anywhere you use the anon key in public code, switch to the publishable key. This includes web pages, mobile and desktop apps, and any CLI or script that ships to users.
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 'https://your-project.supabase.co',5 'sb_publishable_...' // was the anon key6)The publishable key carries the same low privileges as the anon key, so your Row Level Security policies behave the same. User authentication through Supabase Auth is unchanged. The user still signs in and gets their own JWT.
Step 3: Swap the secret key in backend code#
Anywhere you use the service_role key on a server you control, switch to a secret key.
1import { createClient } from '@supabase/supabase-js'23const supabaseAdmin = createClient(4 'https://your-project.supabase.co',5 'sb_secret_...' // was the service_role key6)Secret keys add protections the service_role key doesn't have. They return HTTP 401 if used in a browser (matched on the User-Agent header), and you can run a separate key per service so a single leak only forces one rotation.
Secret keys bypass Row Level Security and have full access to your data. Keep them on backends you control, out of source control, and out of client code.
Database Webhooks and pg_net#
Calls made from Postgres with pg_net, including Database Webhooks, usually send the service_role key on the Authorization: Bearer header. The new secret keys aren't JWTs, so they're rejected there. Send the secret key on the apikey header instead.
1-- before2select net.http_post(3 url := 'https://your-project.supabase.co/functions/v1/your-function',4 headers := jsonb_build_object(5 'Content-Type', 'application/json',6 'Authorization', 'Bearer <service_role key>'7 ),8 body := jsonb_build_object('event', 'ping')9);1011-- after12select net.http_post(13 url := 'https://your-project.supabase.co/functions/v1/your-function',14 headers := jsonb_build_object(15 'Content-Type', 'application/json',16 'apikey', 'sb_secret_...'17 ),18 body := jsonb_build_object('event', 'ping')19);For Database Webhooks created in the Dashboard, edit each webhook's HTTP headers: remove the Authorization header that holds the key and add an apikey header with a secret key instead.
Don't hardcode a secret key in SQL or a webhook configuration, where it's stored in plain text. Store it in Vault and read it at call time:
1headers := jsonb_build_object(2 'Content-Type', 'application/json',3 'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key')4)Step 4: Update Edge Functions#
Edge Functions read their keys from environment variables. Supabase adds two new ones to your functions' environment, SUPABASE_PUBLISHABLE_KEYS and SUPABASE_SECRET_KEYS, alongside the legacy SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY. Confirm they exist in the Edge Functions > Secrets section of the Dashboard before you start.
You have two options: a minimal change that swaps which variable you read, or a fuller upgrade to the @supabase/server SDK.
Option 1: Read the new keys from the environment#
For most functions, the only change is how you read the key. The legacy variables held a plain string. The new ones hold a JSON object keyed by name, so you parse them and read the key by name. The key you created in step 1 is named default.
1// before2const secretKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!34// after5const secretKey = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)['default']The publishable keys work the same way through SUPABASE_PUBLISHABLE_KEYS. Read the Managing Secrets guide for more on environment variables in Edge Functions.
If you created more than one secret key in step 1, every key lives in the same SUPABASE_SECRET_KEYS object, each under its own name. Read a non-default key the same way:
1const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)23const defaultKey = secretKeys['default']4const billingKey = secretKeys['billing'] // the secret key you named "billing"Send publishable and secret keys on the apikey header only. If you also pass the key on the Authorization: Bearer header, which many Supabase clients do by default, the platform tries to parse it as a JWT and rejects the request with Invalid JWT. The platform's built-in verify_jwt check only understands the legacy JWT-based keys, so set verify_jwt = false for these functions and authorize the request in your own code, or let the @supabase/server SDK do it for you (Option 2).
1[functions.my-function]2verify_jwt = falseOption 2: Adopt the @supabase/server SDK#
To get the most out of the new key model, migrate to the @supabase/server SDK. It removes the client-setup boilerplate every function repeats: reading keys from the environment, parsing the Authorization header, and initializing a user-scoped client and a separate admin client. You declare who can call the function, and get both clients ready to use on ctx (ctx.supabase respects Row Level Security, ctx.supabaseAdmin uses the secret key). This is the recommended approach for new functions.
Wrap your existing Deno.serve handler with withSupabase and declare an auth mode for who can call it. Keep verify_jwt = false so the SDK does the authorization.
For a function your users call from the client, use auth: 'user'. The SDK validates the user's session JWT and gives you a client scoped to their Row Level Security policies.
1import { withSupabase } from 'npm:@supabase/server'23Deno.serve(4 withSupabase({ auth: 'user' }, async (_req, ctx) => {5 // ctx.supabase is scoped to the authenticated user6 return Response.json({ email: ctx.userClaims?.email })7 })8)For a function called by your own backend, a worker, or pg_net, use auth: 'secret'. The SDK validates the secret key and gives you a client that bypasses Row Level Security.
1import { withSupabase } from 'npm:@supabase/server'23Deno.serve(4 withSupabase({ auth: 'secret' }, async (_req, ctx) => {5 // ctx.supabaseAdmin is authenticated with a valid secret key6 return Response.json({ ok: true })7 })8)To accept a specific named key instead of default, add its name after the mode with a colon. For example, auth: 'secret:billing' validates the request against the secret key you named billing, and auth: 'publishable:web' against a publishable key named web.
withSupabase returns a standard request handler, so you can also export it as a fetch handler instead of passing it to Deno.serve:
1import { withSupabase } from 'npm:@supabase/server'23export default {4 fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {5 // ctx.supabase is scoped to the authenticated user6 return Response.json({ email: ctx.userClaims?.email })7 }),8}export default { fetch } is equivalent to Deno.serve(...): both define a request handler. The fetch style is portable across Edge Functions, Cloudflare Workers, and Bun, so prefer it if you want the same function to run in more than one environment. Deno.serve keeps working on Edge Functions, so you can leave it in place during a migration and switch later.
A good way to try this is to duplicate one of your functions and migrate the copy first. See Securing Edge Functions for every auth mode and use case, and Authorization headers for how the headers work.
Step 5: Verify nothing uses the legacy keys#
Before turning the legacy keys off, confirm nothing still depends on them. There's no automatic usage indicator, so this is a manual check. Go through every place that holds a Supabase key and make sure it now uses a publishable or secret key.
Don't forget callers that are easy to miss:
- Mobile or desktop app versions already in users' hands.
- CI/CD pipelines and deployment scripts.
- Third-party integrations and webhooks.
- Cron jobs, workers, and
pg_netcalls or Database Webhooks (see Database Webhooks andpg_net).
Step 6: Deactivate the legacy keys#
Once you've confirmed nothing uses the legacy keys, deactivate them in the Settings > API Keys section of the Dashboard. You can re-activate them if you find a client you missed, so this step is reversible.
Known limitations#
A few behaviors differ from the legacy JWT-based keys. Plan for them during the migration:
- You can't send a publishable or secret key in the
Authorization: Bearer ...header. Send it on theapikeyheader instead. - Edge Functions don't verify the
apikeyheader for the new keys. Useverify_jwt = falseand authorize in code, as shown in Step 4. - Public Realtime connections are limited to 24 hours unless the connection is upgraded with user-level authentication through Supabase Auth or a supported third-party auth provider.
Next steps#
After migrating your keys, consider moving to the JWT signing keys system as well. This is a separate, independent migration. The new publishable and secret keys aren't JWTs, so they no longer touch your project's JWT secret. But the access tokens Supabase Auth issues to your users are still signed by that shared secret. Signing keys replace it with rotatable keys you can change without downtime. Together, the two migrations get your whole project off the shared JWT secret.