Scan free

Supabase Service Role Key: Where It Belongs and What If It Leaks

The Supabase service role key is an admin key: requests made with it skip Row Level Security (RLS) entirely and can read, change or delete any row in your project. It belongs only in code that runs on a server you control. If it ever reached a browser, a mobile app or a public repository, treat it as leaked and replace it today.

What is the Supabase service role key?

It is the key that maps requests to the service_role Postgres role, which has the BYPASSRLS attribute. None of your RLS policies apply to it. Supabase now calls the legacy JWT version service_role and its replacement the secret key, sb_secret_.... Both do the same thing, and neither may ever leave your backend.

Your public key runs as anon or authenticated and is only as strong as your policies. The service role key ignores them, which is what makes it useful for admin work and a leak so serious. If you're still sorting out which key is which, start with whether the Supabase anon key is safe.

Supabase service role key vs secret key

Supabase's API keys guide describes the service_role key as the "legacy version of secret keys". The differences:

Legacy service_role keySecret key
FormatLong-lived JWT, starts with eyJShort string, starts with sb_secret_
Postgres roleservice_roleservice_role
Bypasses RLSYesYes
Works from a browserYesNo, Supabase returns 401 for browser User-Agents
How manyOne per projectAs many as you create, so each backend component can have its own
How you retire itDeactivate the legacy keys (reversible)Delete it (irreversible)
StatusBeing deprecated by the end of 2026Recommended

The browser block is a guard rail, not protection. An attacker who has a leaked secret key can still use it from curl or any script. Send secret keys on the apikey header, not Authorization: Bearer, because they aren't JWTs.

Both systems work side by side until you deactivate the legacy keys yourself, as the migration guide describes.

Where to find the Supabase service role key

In the Dashboard, open Settings > API Keys. Every key lives there, legacy or not. The new keys are under the Publishable and secret API keys tab. Older projects may show a Create new API keys button instead, which adds a publishable key and a secret key named default without touching the legacy ones.

From the CLI, supabase projects api-keys --project-ref <your-project-ref> lists a project's keys. For local development, supabase start prints a local secret key that only works against your local stack.

Copy it straight into your host's secret settings, never into chat or a prompt.

Where you can use the Supabase service role key

Only in code that never reaches a user's device:

  • Route handlers and API routes on your server, after you check who is calling.
  • Server actions, with care. In Next.js, a server action is reachable by a direct POST request, not just through your UI. The Next.js data security guide says to verify authentication and authorization inside each one. An action that uses the service role key without that check is an open admin endpoint.
  • Edge Functions, using the key Supabase injects, never a copy you paste in.
  • Cron jobs, workers, queue processors and webhooks that run on your backend.
  • Admin and back-office tools that check the user's permissions first.

Supabase service role key in Next.js

Keep the admin client in its own file, mark it server-only, and read the key from a variable with no public prefix:

ts
// lib/supabase/admin.ts
import 'server-only'
import { createClient } from '@supabase/supabase-js'

export const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SECRET_KEY!, // or SUPABASE_SERVICE_ROLE_KEY on legacy keys
  { auth: { autoRefreshToken: false, persistSession: false } }
)

The server-only import makes the build fail if any client component imports this file, directly or through another module. The auth options are the ones Supabase's admin API reference uses for a server-side client. They stop the client from storing or refreshing a user session. Use this client only after your own code has decided the caller is allowed to do what they asked.

Supabase service role key in an Edge Function

Supabase now recommends not reading the key yourself at all. The @supabase/server SDK checks the caller's secret key and gives you a privileged client:

ts
// supabase/functions/roster/index.ts
import { withSupabase } from 'npm:@supabase/server@1'

export default {
  fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
    // ctx.supabaseAdmin bypasses Row Level Security
    const { data } = await ctx.supabaseAdmin.from('profiles').select('email')
    return Response.json({ data })
  }),
}

That mode is for functions your own backend calls. Set verify_jwt = false for the function in supabase/config.toml, because the platform's JWT check doesn't understand secret keys and the SDK does the check instead. For a function your users call, use auth: 'user', which gives you a client scoped to their RLS policies.

If you build the client yourself, the runtime injects your secret keys as JSON in SUPABASE_SECRET_KEYS, keyed by name:

ts
const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
const secretKey = secretKeys['default']

The legacy SUPABASE_SERVICE_ROLE_KEY variable still exists in the runtime, but it carries the deprecated key. See Edge Function environment variables for the full list.

Where it must never go

  • Any variable with a public prefix. NEXT_PUBLIC_, VITE_ and EXPO_PUBLIC_ values are copied into the code you ship. Next.js inlines them into the JavaScript sent to the browser at build time. Vite says VITE_ variables should not contain API keys. Expo says EXPO_PUBLIC_ values are visible in plain text in the compiled app.
  • Client components, meaning any "use client" file and anything it imports.
  • Mobile apps and desktop apps. Anyone can unpack the binary.
  • Browser extensions. The extension's code is downloaded to every user's machine.
  • Git. Not in .env files, not in CI scripts, not in a commit you later deleted.
  • Logs and URLs. Supabase's guide says never to log a key and never to put one in a query parameter, because those get recorded.

Supabase service role key exposed? Check your build

Your source shows where the key is referenced. Your build output shows whether it shipped. Check both, without printing the key into your terminal or CI logs.

  1. Look for public variable names that hold a secret. From your project root, this prints only the file, line and variable name, never the value:
bash
grep -rnoE "(NEXT_PUBLIC|VITE|EXPO_PUBLIC)_[A-Z0-9_]*(SERVICE_ROLE|SECRET)[A-Z0-9_]*" . \
  --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.next
  1. Build the app, then search the browser-facing output for the new key format. In Next.js that is .next/static (.next/server holds server code and is allowed to contain the key). In Vite it is dist. The -l flag prints file names only, and no output means no match:
bash
grep -rl "sb_secret_" .next/static
  1. Check every JWT in the bundle for the legacy key. A legacy key is base64-encoded, so its "role":"service_role" claim never appears as plain text. This decodes each token locally and prints only its role (use dist instead of .next/static for Vite):
bash
grep -rhoE 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' .next/static \
  | sort -u \
  | node -e 'for (const t of require("fs").readFileSync(0, "utf8").split("\n").filter(Boolean)) { try { console.log(JSON.parse(Buffer.from(t.split(".")[1], "base64url")).role ?? "(no role)") } catch { console.log("(not a JWT)") } }'
  1. Read the result. anon is your public key and is expected. Any service_role line, or any file name from step 2, means the key is in your shipped code.
  2. Check the live site too. Open it in Chrome, open DevTools, and search across all loaded files (Cmd+Option+F on a Mac, Ctrl+Shift+F elsewhere) for sb_secret_. Don't paste a token you find into an online decoder. Use the command above.
  3. Check your git history. A key that was committed once is still in the history after you delete the file. What to do after pushing a .env file to GitHub covers finding it and what to do next.

A free Tuvio scan reads git history, the full history where it can, and flags committed secrets, including JWT-shaped tokens. It doesn't recognize Supabase keys by name: a legacy service_role key shows up as a generic JWT finding, so the checks above stay the direct test.

What to do if your Supabase service role key was exposed

Supabase's procedure for a leaked key starts with: don't rush. Fix whatever leaked it first, or the new key leaks the same way.

  1. Fix the cause. Move the key out of the public variable or client file, rebuild and redeploy, so no new build ships it.
  2. Create a new secret key under Settings > API Keys. If the leaked key was the legacy service_role key, its replacement is a secret key.
  3. Replace the key everywhere it's used: your host's environment variables, CI secrets, workers, cron jobs and Edge Functions. Database Webhooks and pg_net calls often send the legacy key on an Authorization header. Secret keys go on the apikey header, and the migration guide suggests storing them in Vault rather than in plain SQL.
  4. Confirm every component now uses the new key. There is no automatic usage indicator, so this is a manual check.
  5. Retire the leaked key. Delete a leaked secret key. That can't be undone, so finish step 4 first. For a leaked legacy key, deactivate the legacy keys on the same page. The docs describe this as deactivating the legacy keys, plural, so assume the anon key goes off too and move your client code, including app versions already on people's phones, to the publishable key first. Deactivation can be reversed if you find a client you missed.

How do I rotate the Supabase service role key on legacy keys?

The legacy anon and service_role keys are JWTs signed with your project's legacy JWT secret. Under that system, the JWT signing keys guide notes, rotating meant changing anon and service_role together, with downtime, and every signed-in user was signed out.

Today the path is to move off them: switch to publishable and secret keys and deactivate the legacy ones, as above. If the legacy JWT secret itself was exposed, the same guide lists that as a reason to move to JWT signing keys, rotate to the new signing key, and then revoke the legacy secret. To avoid signing users out, it suggests waiting until current access tokens expire (with a 1-hour expiry, at least 1 hour and 15 minutes). In an active incident you may choose to revoke it immediately.

After you rotate: logs and data

  • Logs. In the Dashboard's Logs, read the API Gateway, PostgREST and Auth logs for the exposure window, looking for requests your app wouldn't make. Retention depends on your plan, so look soon.
  • Auth. Check for accounts you don't recognize and for changes to users' app_metadata, which users can't edit themselves, so a change there came from a privileged key or your own backend.
  • Data and Storage. Look for rows that changed when nobody was using the app, deleted records, and new or replaced files in Storage buckets.
  • Advisors. Run the Security Advisor and fix what it reports before you call the incident closed.

If personal data may have been read, check what notification duties apply to you where you and your users are.

Supabase service role key not bypassing RLS

If your admin client suddenly gets RLS errors or empty results, the key usually isn't the problem. Supabase's RLS guide says a secret key bypasses RLS only when the request carries no user access token. If the request carries one, it runs under that user's policies, even when the client was created with a secret key. Its troubleshooting article names three common ways a user token gets in:

  1. An SSR client created with the service role key. The @supabase/ssr clients share the user's session from cookies, and that session replaces your key. Fix: keep a separate admin client built with @supabase/supabase-js, like lib/supabase/admin.ts above.
  2. An Authorization header set by hand. Passing the user's token in the client options turns the client into that user:
ts
// This client runs as the signed-in user, not as service_role
const supabase = createClient(url, secretKey, {
  global: { headers: { Authorization: req.headers.get('Authorization')! } },
})
  1. Auth calls on the admin client. signUp and other sign-in methods put a user session into the client that made the call. To create a user from server code, use supabase.auth.admin.createUser(), and keep the admin client separate from anything that signs users in.

Two more things to check. Adding service_role to a policy does nothing, because policies never run for it. And grants are checked before RLS: a missing grant returns a 42501 permission error even for service_role. If a table was created without the default grants, grant service_role what it needs in a migration, as shown in Securing your API.

A checklist for your service role key

  • Only server-side variables hold it, with no NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_ prefix.
  • The admin client is one server-only file, separate from your SSR client.
  • Every route handler and server action using it checks the caller first.
  • Edge Functions use the injected key, ideally through @supabase/server.
  • Your build output has no sb_secret_ string and no service_role JWT.
  • The key was never committed, or has been rotated since.
  • You have secret keys and a plan to deactivate the legacy keys before the end of 2026.

RLS can't protect you from this key, so keep it on the server. For the policies that protect everything else, see Supabase RLS policy examples.