In short
- The service role key runs as
service_role, which hasBYPASSRLS: 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_orNEXT_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_rolekey can no longer be rolled on its own: switch to ansb_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
- What someone can do with your service role key
- Where to find your Supabase service role key
- Where the service role key may live, and where it must not
- How the service role key ends up in the browser
- How to check your app for an exposed service role key
- The fix: keep the admin key behind your own check
- Service role key exposed? Rotate it now
- After rotating: find out what was read or changed
- The traps after the obvious fix
- What Keybrum tests for the service role key
- Questions
- Sources
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 key | Secret key | |
|---|---|---|
| Looks like | eyJhbGciOi... (a JWT) | sb_secret_... |
| Access | service_role, skips RLS | service_role, skips RLS |
| Sent from a browser | Accepted | Refused with HTTP 401, matched on the User-Agent |
| How many per project | One | As many as you create, for example one per backend |
| Replacing it | Tied to the anon key and the JWT secret | Create a new one, delete the old one |
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 Supabase | With the anon or publishable key | With the service role key |
|---|---|---|
Tables and views (/rest/v1/) | What your RLS policies allow | Read, change and delete any row, whatever the policies say |
Database functions (/rest/v1/rpc/) | Functions granted to anon | Any function the service_role role may execute |
| Storage | Public buckets, plus what bucket policies allow | Every file in every bucket, private ones included |
Auth admin API (/auth/v1/admin/) | Nothing | List 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
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
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
For the legacy service_role key
It sits on the legacy keys tab of the same page, next to the
anonkey. Use it only to keep an existing backend running until you switch it to a secret key. - 4
From the command line
supabase projects api-keys --project-ref your-project-reflists a hosted project's keys.supabase statusprints the local stack's keys, which only open your local database. - 5
In an Edge Function
You do not copy it: Supabase injects
SUPABASE_SECRET_KEYS(your secret keys, by name) and the legacySUPABASE_SERVICE_ROLE_KEYinto every function, and recommends its@supabase/serverhelper 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:
| Place | Admin key allowed? | How |
|---|---|---|
| Server route, server action, worker, cron job | Yes | An unprefixed variable such as SUPABASE_SECRET_KEY, read only by server code, ideally one key per backend |
| Supabase Edge Function | Yes | The injected keys, through @supabase/server or SUPABASE_SECRET_KEYS |
| CI pipeline | Yes, as a CI secret | The CI tool's secret storage, never the workflow file, never printed in a log |
Database Webhook or pg_net call | Yes, from Vault | Read from Supabase Vault at call time, never pasted into SQL or a webhook header |
Browser code, any VITE_ or NEXT_PUBLIC_ variable | No | Use the publishable key and RLS policies |
| Mobile, desktop or CLI app you distribute | No | Anything inside the package can be extracted |
| Git repository, chat, email, a ticket | No | Share 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 withNEXT_PUBLIC_. Name the admin keyVITE_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.tsimported 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
.envserved 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).
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 {}
}
}' distanon 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
# 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.
# 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:
// 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:
// 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 } }
)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
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
Move every backend to it
Servers, Edge Functions, cron jobs, CI secrets. In Database Webhooks and
pg_netcalls, put the secret key in theapikeyheader, notAuthorization: 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
Move the frontend to the publishable key
Deactivating the legacy keys turns off the
anonkey too, so switch browser and mobile clients first. Installed mobile versions keep the old key until users update. - 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
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:
-- 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;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_atcolumns 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
Authorizationheader, so RLS applies again. Use a separate plainsupabase-jsclient for admin work. - Secrets kept in readable tables. A
settingstable 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_roletoken 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.Understanding API keys, Supabase Docs
- 2.Rotate a leaked or compromised API key, Supabase Docs
- 3.Migrating to publishable and secret API keys, Supabase Docs
- 4.JWT signing keys, Supabase Docs
- 5.Rotating anon, service, and JWT secrets, Supabase Knowledge Base
- 6.Why is my service role key client getting RLS errors or not returning data?, Supabase Knowledge Base
- 7.Securing Edge Functions, Supabase Docs
- 8.Edge Functions environment variables, Supabase Docs
- 9.Auth admin API reference, Supabase Docs
- 10.Query logs with SQL, Supabase Docs
- 11.Supabase pricing (log retention), Supabase
- 12.Upcoming changes to Supabase API keys, Supabase on GitHub
- 13.Rotating secrets for Vercel Marketplace integrations, Vercel Knowledge Base
- 14.Env variables and modes, Vite
- 15.Environment variables, Next.js
- 16.How we discovered over 2k high-impact vulnerabilities in apps built with vibe coding platforms, Escape