# The blast radius of a leaked Supabase service-role key

> The key bypasses every policy for every table. Here is how it leaks, what an attacker gets, how to contain it, and how to make the leak impossible.

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:

```bash
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

1. **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.
2. **Redeploy** everything that used it, with the new value. Anything not
   updated is now broken, which is the correct failure.
3. **Look for what was done, not just what was read.** Check `app_metadata` on
   every user for unexpected roles, check for rows created or modified in the
   exposure window, check `auth.users` for changed email addresses.
4. **Rotate the JWT secret too** if you believe tokens were minted. That signs
   everyone out, which is the point.
5. **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:

```bash
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:

```bash
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 `.tsx` file mentions it.
- Every `adminQuery` call 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
