Supabase gives you two keys with very different characters, and the difference is the single most important security fact about the platform.
The publishable (anon) key is designed to be public. It ships in your JavaScript bundle. Every request made with it is filtered by row-level security, so it can only ever see what a policy allows. A leak of this key is not an incident; if it does expose data, the bug is a missing policy, not the key.
The service-role key bypasses row-level security completely. Not "has an admin role": there is no role, no policy evaluation, nothing between it and every row of every table. Anyone holding it can read your entire database, rewrite it, and delete it, from anywhere on the internet, because the REST endpoint is public and the key is the only credential it needs.
How it actually leaks
Almost never through a dramatic breach. In order of frequency:
A NEXT_PUBLIC_ prefix. Someone renames the variable to fix a build error
about an undefined value in a client component. The build succeeds. The key is
now compiled into a JavaScript file served to every visitor.
An import that crosses the boundary. A helper that uses the admin client
gets imported by a shared utility, which is imported by a component that later
gains "use client". The bundler follows the graph.
A commit. .env.local is gitignored; .env.production is not, in some
templates. Or a screenshot in an issue. Or a console.log of process.env
during a debugging session that gets committed.
A log line. An error handler that serialises the request context, an
analytics event with too much detail, a third-party error reporter's
beforeSend that was never configured.
A preview environment. Production keys copied into a preview project, whose environment variables are visible to everyone with dashboard access, including contractors.
What an attacker gets
With the URL (which is public by design) and the service-role key:
curl "https://<ref>.supabase.co/rest/v1/users?select=*" \
-H "apikey: <service-role-key>" \
-H "Authorization: Bearer <service-role-key>"
Every row. Then PATCH and DELETE on any table. Plus the admin auth API:
listing users, changing emails, generating sign-in links for any account,
setting app_metadata, that last one means promoting themselves to admin in
your own app, which survives the key being rotated if you do not check.
There is no rate limit that helps and no per-table restriction. The blast radius is the database.
If it leaks: contain it in this order
- Rotate. Dashboard → Project Settings → API keys → roll the service key. The old one stops working immediately. Do this before investigating anything; the investigation can happen while the door is shut.
- Redeploy everything that used it, with the new value. Anything not updated is now broken, which is the correct failure.
- Look for what was done, not just what was read. Check
app_metadataon every user for unexpected roles, check for rows created or modified in the exposure window, checkauth.usersfor changed email addresses. - Rotate the JWT secret too if you believe tokens were minted. That signs everyone out, which is the point.
- Assume the data was read. Rotation stops the future. Handle the disclosure obligations for whatever was reachable, which, unless you can prove otherwise, is everything.
Making the leak impossible instead
One reader. The key is read in exactly one function, in one file, which
throws if typeof window !== "undefined". Every other module goes through it.
import "server-only" at the top of every module that touches it, so an
import from a client component is a build error, not a runtime surprise.
One call site pattern. adminQuery(reason, fn) requires a written
justification, which makes grep -rn "adminQuery(" src/ a complete audit of
every bypass in the codebase. A reviewer can read that list in a minute.
Prove the negative in CI-free checks. Before shipping:
grep -rn "SUPABASE_SERVICE_ROLE_KEY" src/ --include="*.tsx"
grep -rn "NEXT_PUBLIC_SUPABASE_SERVICE" .
Both must print nothing. And after a build, search the client bundle:
grep -rl "$(printf '%s' "$SUPABASE_SERVICE_ROLE_KEY" | cut -c1-12)" .next/static/ || echo "clean"
Different keys per environment. A leaked preview key should not open production. This also means a contractor with preview access has never held the production credential.
The mindset that keeps it safe
Every use of the admin client is a decision to switch off the database's security model for one query. Sometimes that is right: a signed webhook with no user session, a background job, writing a role. Each of those has a reason you can say out loud.
"The RLS policy is blocking me" is not one of them. That sentence almost always means the policy does not yet describe the access rule, and fixing the policy protects every future query, while the bypass protects nothing and quietly becomes permanent.
Checking your work
- The key appears in exactly two source files, both server-only.
- No
.tsxfile mentions it. - Every
adminQuerycall has a justification a stranger would accept. - Preview and production hold different keys.
- You know where you would click to rotate it, without looking it up.