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

URL: https://tuvio.dev/blog/supabase-service-role-key
Published: 2026-10-19
By the Tuvio team

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](https://tuvio.dev/blog/supabase-anon-key-safe).

### Supabase service role key vs secret key

Supabase's [API keys guide](https://supabase.com/docs/guides/getting-started/api-keys) describes the `service_role` key as the "legacy version of secret keys". The differences:

| | Legacy `service_role` key | Secret key |
| --- | --- | --- |
| Format | Long-lived JWT, starts with `eyJ` | Short string, starts with `sb_secret_` |
| Postgres role | `service_role` | `service_role` |
| Bypasses RLS | Yes | Yes |
| Works from a browser | Yes | No, Supabase returns 401 for browser `User-Agent`s |
| How many | One per project | As many as you create, so each backend component can have its own |
| How you retire it | Deactivate the legacy keys (reversible) | Delete it (irreversible) |
| Status | Being deprecated by the end of 2026 | Recommended |

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](https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys) 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](https://nextjs.org/docs/app/guides/data-security) 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](https://supabase.com/docs/reference/javascript/admin-api) 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](https://supabase.com/docs/guides/functions/secrets) 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
```

2. **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
```

3. **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)") } }'
```

4. **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.
5. **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.
6. **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](https://tuvio.dev/blog/accidentally-pushed-env-file-to-github) covers finding it and what to do next.

A [free Tuvio scan](https://tuvio.dev/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](https://supabase.com/docs/guides/auth/signing-keys) 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](https://supabase.com/docs/guides/troubleshooting/why-is-my-service-role-key-client-getting-rls-errors-or-not-returning-data-7_1K9z) 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')! } },
})
```

3. **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](https://supabase.com/docs/guides/api/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](https://tuvio.dev/blog/supabase-rls-policy-examples).
