Supabase service role key: where to find it, where it belongs, what to do if it leaks

The Supabase service role key is your project's admin key: requests that carry it run as the service_role Postgres role, which skips every row level security policy. It belongs on servers, Edge Functions and CI secret stores, never in a browser, a mobile app or a VITE_ or NEXT_PUBLIC_ variable. If it leaked, removing it from the code is not enough: replace it with a secret key and deactivate the old one.

The team behind Keybrum's attacksUpdated 13 min read

In short

  • The service role key runs as service_role, which has BYPASSRLS: every policy is skipped, and Storage and the Auth admin API open too.
  • It belongs on servers, Edge Functions, workers and CI secret stores. Never in a VITE_ or NEXT_PUBLIC_ variable, an app package or a repository.
  • Deleting a leaked key from your code does not revoke it: old builds and copies keep working until you retire the key in Settings > API Keys.
  • A legacy service_role key can no longer be rolled on its own: switch to an sb_secret_ key, deactivate the legacy keys, revoke the legacy JWT secret.
  • Check your logs the same day: Supabase keeps API logs for 1 day on the Free plan and 7 days on Pro.
On this page

What the Supabase service role key is

The service role key is the admin key of a Supabase project. Every request that carries it runs as the service_role Postgres role, and that role has the BYPASSRLS attribute: Postgres skips every row level security (RLS) policy you wrote. Supabase's documentation puts it plainly: this key is for code you control, such as a server, an Edge Function or a cron job, and it must never leave your control.

It comes in two formats. Projects on the legacy keys have a service_role key, a JSON Web Token (JWT) signed with the project's JWT secret, starting with eyJ. The newer secret keys start with sb_secret_. Both give the same access. Supabase is deprecating the legacy anon and service_role keys by the end of 2026 (the publishable key comparison covers the public side), so the fixes below double as your migration.

Legacy service_role keySecret key
Looks likeeyJhbGciOi... (a JWT)sb_secret_...
Accessservice_role, skips RLSservice_role, skips RLS
Sent from a browserAcceptedRefused with HTTP 401, matched on the User-Agent
How many per projectOneAs many as you create, for example one per backend
Replacing itTied to the anon key and the JWT secretCreate a new one, delete the old one
The browser block on secret keys stops accidents, not attackers: anyone who copies the key can still use it from a script. Paste a key in the Supabase key checker to see which one you hold.

What someone can do with your service role key

Holding the key means talking to your project as its administrator, from anywhere, without going through your app.

Part of SupabaseWith the anon or publishable keyWith the service role key
Tables and views (/rest/v1/)What your RLS policies allowRead, change and delete any row, whatever the policies say
Database functions (/rest/v1/rpc/)Functions granted to anonAny function the service_role role may execute
StoragePublic buckets, plus what bucket policies allowEvery file in every bucket, private ones included
Auth admin API (/auth/v1/admin/)NothingList every user, create accounts, change any user's email or password, delete accounts

The last row is the one people forget: Supabase documents that admin changes to a user apply directly, without confirmation flows. And since these requests go straight to Supabase, none of them pass through your app's code or its logs.

Where to find your Supabase service role key

  1. 1

    Open the API Keys settings

    In the Supabase dashboard, open your project, then Settings > API Keys. Every key lives on this page, legacy or not.

  2. 2

    For a secret key

    Open the Publishable and secret API keys tab and copy a secret key, or create one for the backend that will use it. A Create new API keys button means the project still runs on legacy keys only: creating the new pair is safe, and the old keys keep working.

  3. 3

    For the legacy service_role key

    It sits on the legacy keys tab of the same page, next to the anon key. Use it only to keep an existing backend running until you switch it to a secret key.

  4. 4

    From the command line

    supabase projects api-keys --project-ref your-project-ref lists a hosted project's keys. supabase status prints the local stack's keys, which only open your local database.

  5. 5

    In an Edge Function

    You do not copy it: Supabase injects SUPABASE_SECRET_KEYS (your secret keys, by name) and the legacy SUPABASE_SERVICE_ROLE_KEY into every function, and recommends its @supabase/server helper over reading them by hand.

Where the service role key may live, and where it must not

Supabase sums up the rule in one line: anything you ship gets the publishable key, anything you control gets the secret key. In practice:

PlaceAdmin key allowed?How
Server route, server action, worker, cron jobYesAn unprefixed variable such as SUPABASE_SECRET_KEY, read only by server code, ideally one key per backend
Supabase Edge FunctionYesThe injected keys, through @supabase/server or SUPABASE_SECRET_KEYS
CI pipelineYes, as a CI secretThe CI tool's secret storage, never the workflow file, never printed in a log
Database Webhook or pg_net callYes, from VaultRead from Supabase Vault at call time, never pasted into SQL or a webhook header
Browser code, any VITE_ or NEXT_PUBLIC_ variableNoUse the publishable key and RLS policies
Mobile, desktop or CLI app you distributeNoAnything inside the package can be extracted
Git repository, chat, email, a ticketNoShare through a password manager, or let the teammate read it in the dashboard

How the service role key ends up in the browser

Nobody pastes an admin key into a web page on purpose. These are the usual paths:

  • A public prefix. At build time, Vite writes the value of any VITE_ variable your code reads into the JavaScript, and Next.js does the same with NEXT_PUBLIC_. Name the admin key VITE_SUPABASE_SERVICE_ROLE_KEY, read it in the client, and every visitor downloads it.
  • An RLS error fixed with the admin key. A query fails on a policy, and the quickest change that makes the screen work is swapping in the service role key in the frontend client. A coding agent asked to make it work can make the same swap, and nothing fails afterwards.
  • A shared client module. One lib/supabase.ts imported by server and browser code. Someone adds an admin client to it, and the bundler follows the import into the browser build.
  • A committed `.env` file. The key stays in the Git history after you delete the file. A .env served by your web host is the same leak, see exposed .env files.
  • Logs and copies. A CI log, an error tracker, a debug route that dumps the environment, a Database Webhook header saved in plain text, a table row that RLS leaves readable.

How to check your app for an exposed service role key

Check the files you ship: the leak lives in the built JavaScript, often only in the production build where the variable is set.

1. Scan the live app

Paste your app's address in the free check. In about ten seconds it shows the backend and the keys exposed in the JavaScript a visitor downloads. No account, and nothing is sent to your Supabase project.

2. Scan your build output

Build the app, then run this from the project folder. It finds sb_secret_ keys and prints the role inside every JWT. Use dist for Vite apps and .next/static for Next.js (.next/server is server code and may hold the key legitimately).

bash
node -e '
const fs = require("fs"), path = require("path");
const walk = (d) => fs.readdirSync(d, { withFileTypes: true })
  .flatMap((e) => e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
for (const file of walk(process.argv[1])) {
  const text = fs.readFileSync(file, "utf8");
  if (/sb_secret_\w/.test(text)) console.log("SECRET KEY (sb_secret_) in", file);
  for (const jwt of text.match(/eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+/g) ?? []) {
    try {
      const { role } = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url"));
      if (role) console.log(role === "service_role" ? "SERVICE ROLE KEY in" : `${role} key in`, file);
    } catch {}
  }
}' dist
An anon key line is expected. SERVICE ROLE KEY or SECRET KEY in a browser file is a leak: go to the rotation steps below.

3. Search the Git history

bash
# Was an env file ever committed?
git log --all --oneline -- .env .env.local .env.production supabase/functions/.env

# Which commits added or removed a secret key, or a variable that holds one?
git log --all --oneline -G "sb_secret_|SERVICE_ROLE"

A hit that only reads process.env.SUPABASE_SECRET_KEY in server code is fine. A hit that contains the key itself means anyone who has, or ever had, access to the repository can use it, even if a later commit removed it.

The fix: keep the admin key behind your own check

Taking the key out of the frontend is half the fix. The feature that needed it still has to work, so move that one action into server code that checks who is calling before it touches the admin client.

bash
# Bad: VITE_ and NEXT_PUBLIC_ values are copied into the browser bundle
VITE_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...

# Good: the browser gets the project URL and the publishable key, nothing else
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...

For an admin action, an Edge Function with Supabase's @supabase/server helper does the plumbing. This one lets an admin delete a user account and refuses everyone else:

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

export default {
  fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
    // 1. Who is calling? ctx.supabase runs with the caller's own RLS policies.
    const { data: me } = await ctx.supabase
      .from('profiles')
      .select('is_admin')
      .eq('id', ctx.userClaims!.id)
      .single()
    if (!me?.is_admin) return new Response('Forbidden', { status: 403 })

    // 2. Only now use the admin client, which skips RLS.
    const { userId } = await req.json()
    const { error } = await ctx.supabaseAdmin.auth.admin.deleteUser(userId)
    return Response.json({ ok: !error }, { status: error ? 400 : 200 })
  }),
}

ctx.supabase runs with the caller's own RLS policies, so the admin check cannot be skipped from the browser. ctx.supabaseAdmin holds the secret key and never leaves the function. Make sure users cannot update their own is_admin value through a policy, or the check is worth nothing.

In Next.js, give the admin client its own file and mark it server-only. Next.js then raises an error if a Client Component imports it:

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!, // no NEXT_PUBLIC_ prefix
  { auth: { autoRefreshToken: false, persistSession: false } }
)
The auth options follow Supabase's example for a server-side admin client: a server has no user session to store or refresh.

Service role key exposed? Rotate it now

If the key reached a browser, a public repository or a log other people can read, treat it as used. Deleting it from the code does not revoke it: old builds and copies keep it, and legacy service_role keys were issued with a 10-year expiry. Fix the cause first so the new key does not leak the same way, unless someone is using the key right now: then retire it at once, even if parts of your app break for a while.

On projects that have not moved to the new JWT signing keys, Supabase no longer lets you roll the legacy keys or the JWT secret. The way out is the migration itself:

  1. 1

    Create a secret key

    In Settings > API Keys, on the Publishable and secret API keys tab. If the project has none yet, Create new API keys adds a publishable key and a secret key, both named default.

  2. 2

    Move every backend to it

    Servers, Edge Functions, cron jobs, CI secrets. In Database Webhooks and pg_net calls, put the secret key in the apikey header, not Authorization: Bearer, since it is not a JWT, and read it from Vault. Redeploy each one: on hosts like Vercel, a deployment keeps the values it was built with.

  3. 3

    Move the frontend to the publishable key

    Deactivating the legacy keys turns off the anon key too, so switch browser and mobile clients first. Installed mobile versions keep the old key until users update.

  4. 4

    Deactivate the legacy keys

    On the same page. If you find a client you missed, give it a new key: re-enabling the legacy keys brings the leaked key back to life.

  5. 5

    Revoke the legacy JWT secret

    The leaked key is a JWT signed by that secret, so anything that trusts the secret accepts it. On the JWT Keys page, click Migrate JWT secret, then Rotate keys, then revoke the legacy secret under Previously used. If your access tokens expire after 1 hour, Supabase advises waiting at least 1 hour 15 minutes before revoking, so signed-in users are not signed out; if someone is using the key right now, revoke at once. Update any code that verifies tokens with the legacy secret before you rotate.

If the leaked key is an sb_secret_ key, it is shorter: create a new secret key, move your backends to it, then delete the leaked one, which cannot be undone. An Edge Function using withSupabase({ auth: 'secret' }) only accepts the key named default, so point it at the new name first, for example auth: 'secret:rotated_key'. The leaked API key guide covers your other providers.

After rotating: find out what was read or changed

Rotation closes the door but does not tell you who came in. Start with the logs today: Supabase keeps API and database logs for 1 day on the Free plan, 7 days on Pro, 28 on Team and 90 on Enterprise.

Calls to the Auth admin API are the clearest signal, because only an admin key can make them. In the dashboard Explorer, choose Run SQL, set the query source to Logs, pick a time range that starts when the key went public, and run:

sql
-- Calls to the Auth admin API: only an admin key can make them.
select log_attributes['request.headers.cf_connecting_ip'] as ip,
       log_attributes['request.method'] as method,
       log_attributes['request.path'] as path,
       log_attributes['response.status_code'] as status,
       count() as calls,
       min(timestamp) as first_seen,
       max(timestamp) as last_seen
from logs
where source = 'edge_logs'
  and log_attributes['request.path'] like '/auth/v1/admin%'
group by ip, method, path, status
order by calls desc
limit 100;
A successful call from an IP that is not one of your servers means someone used the key. To list writes to your tables, replace the path filter with log_attributes['request.path'] like '/rest/v1/%' and log_attributes['request.method'] in ('POST', 'PATCH', 'DELETE'). These logs do not record which key made a request, so compare the IPs with your own servers.
  • Auth > Users: accounts you did not create, emails or passwords changed recently, users banned or deleted.
  • Tables: rows changed or deleted during the window, using your updated_at columns or your latest backup.
  • Storage: files deleted or replaced in private buckets.
  • Other secrets: API keys stored in your tables (Stripe, OpenAI, email providers) were readable too. Rotate them.
  • Personal data: if user data could be read, check what the law where you operate requires you to tell users or a regulator.

The traps after the obvious fix

  • An admin endpoint anyone can call. An Edge Function or API route that uses the admin key without checking the caller leaks the same data, one request away. Supabase warns that querying through the admin client without filtering by the caller's ID returns every user's rows. That is an IDOR: test it as a stranger, then as a second account.
  • Legacy keys switched back on. A forgotten cron job breaks, someone re-enables the legacy keys to fix it, and the leaked key works again.
  • The admin key in an SSR client. Supabase's SSR helpers carry the user session from cookies, which replaces the admin key in the Authorization header, so RLS applies again. Use a separate plain supabase-js client for admin work.
  • Secrets kept in readable tables. A settings table with RLS off that stores the admin key hands it to anyone holding the publishable key. The RLS guide shows how to list those tables.
  • A weak JWT secret. On a self-hosted project still using a documented default or a guessable JWT secret, anyone can sign their own service_role token without ever seeing your key. Supabase tells self-hosters never to start with the example values.

What Keybrum tests for the service role key

Keybrum has a dedicated attack for this key. It looks for service_role JWTs and sb_secret_ keys in your live JavaScript, then checks whether they open the Auth admin API, counting users and tables and reading nothing else. After you rotate, it tests the old key again: a key that left your code but still works stays an open finding until Supabase rejects it.

The same run checks the paths around it: .env files served by your host, source maps, API keys in tables a stranger can read, and the anon key's signature against known default JWT secrets. Your coding agent reads the findings and fixes through Keybrum's MCP server. For the public half of the pair, read the anon key guide; for other server-only keys, see API keys exposed in the frontend.

Questions

Where do I find the Supabase service role key?

In the dashboard, open Settings > API Keys. The legacy service_role key is on the legacy keys tab next to the anon key, and the newer secret keys (sb_secret_...) are on the Publishable and secret API keys tab. With the CLI, supabase projects api-keys --project-ref your-project-ref lists them.

Is the Supabase secret key the same as the service role key?

Same access: both run as the service_role role and skip RLS. The secret key is the newer format. It is not a JWT, it is refused when sent from a browser, and you can create one per backend and delete each on its own. Supabase is deprecating the legacy service_role key by the end of 2026.

What happens if my Supabase service role key is exposed?

Anyone who has it can read, change and delete every row, file and user account in the project, whatever your RLS policies say. Removing it from the code does not stop that. Replace it with a secret key, deactivate the legacy keys, revoke the legacy JWT secret, then read your logs while they still exist.

Can I use the service role key in a Supabase Edge Function?

Yes, it is one of the places it belongs, and you do not need to paste it: Supabase injects your keys into every function. Check the caller before you use the admin client, for example with withSupabase({ auth: 'user' }), or the function becomes a public door to your data.

How do I get a new Supabase service role key?

Supabase no longer lets you roll the legacy key on its own. Create a secret key in Settings > API Keys and use it wherever the old key was: it does the same job. If the old key leaked, also deactivate the legacy keys and revoke the legacy JWT secret.

Sources

  1. 1.Understanding API keys, Supabase Docs
  2. 2.Rotate a leaked or compromised API key, Supabase Docs
  3. 3.Migrating to publishable and secret API keys, Supabase Docs
  4. 4.JWT signing keys, Supabase Docs
  5. 5.Rotating anon, service, and JWT secrets, Supabase Knowledge Base
  6. 6.Why is my service role key client getting RLS errors or not returning data?, Supabase Knowledge Base
  7. 7.Securing Edge Functions, Supabase Docs
  8. 8.Edge Functions environment variables, Supabase Docs
  9. 9.Auth admin API reference, Supabase Docs
  10. 10.Query logs with SQL, Supabase Docs
  11. 11.Supabase pricing (log retention), Supabase
  12. 12.Upcoming changes to Supabase API keys, Supabase on GitHub
  13. 13.Rotating secrets for Vercel Marketplace integrations, Vercel Knowledge Base
  14. 14.Env variables and modes, Vite
  15. 15.Environment variables, Next.js
  16. 16.How we discovered over 2k high-impact vulnerabilities in apps built with vibe coding platforms, Escape

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub