Supabase keys: anon versus service-role, and the blast radius of each
Why the anon/publishable key is public only while RLS actually enforces policies, and why service-role exposure is total — with tests for both.
Supabase keys: anon versus service-role, and the blast radius of each
Supabase issues a key you are meant to publish alongside one that means total compromise. This covers what each tier actually authorizes, the single condition that makes publishing correct design, and how to test that condition instead of assuming it.
Supabase produces more confusion than any other mainstream provider, because it issues keys that are meant to be published next to keys that mean total compromise — and the difference isn't in the keys at all. It's in your Postgres policies. Most explanations stop at "anon is safe to expose, service-role isn't." That phrasing is missing the load-bearing clause: anon is safe to expose only while Row Level Security actually enforces your intended access rules, on every table, for every path. When that condition fails silently — and it fails silently by default — the public key becomes a full read/write interface to whatever the broken policy exposes.
This guide covers the key tiers precisely (including the newer sb_publishable_/sb_secret_ formats), the exact mechanics of what each can reach, the standard ways the safety condition erodes, and concrete tests for both assumptions. Facts follow Supabase's API-keys documentation and RLS guide, cited inline.
Four keys, two tiers
Current Supabase projects carry two generations of keys simultaneously (Understanding API keys):
| Key | Format | Privilege | Placement |
|---|---|---|---|
| Publishable | sb_publishable_… | Low | Client code, CLIs, anywhere public |
| Secret | sb_secret_… | Elevated | Backend components only |
anon (legacy) | Long-lived JWT | Low | Client code — being superseded by publishable |
service_role (legacy) | Long-lived JWT | Elevated | Backend only — being superseded by secret |
Supabase states the legacy pair "will be deprecated by the end of 2026" (API keys guide), but both generations work side by side until you explicitly disable the old ones — meaning most projects today hold two low-privilege and two high-privilege credentials at once. Inventory matters: scanning or rotation that covers only one generation leaves the other fully live. All example key material below is synthetic placeholder format.
What "public" actually buys an attacker — and what it doesn't
The publishable/anon key authenticates which application is talking to your project. Per the docs' own framing: API keys answer "what is accessing"; Supabase Auth answers "who is accessing." The key does not identify or authorize a user.
Mechanically, every request through the Data API (PostgREST) executes against Postgres under a role determined by the key plus any user JWT:
| Request carries | Postgres role used for RLS |
|---|---|
| Publishable/anon key only | anon |
| Publishable/anon key + user's auth JWT | authenticated |
So the public key hands a stranger exactly this: the ability to run API queries as the anon role — and, if they create accounts, as authenticated. Whatever those roles can reach through enabled, policy-governed tables is the complete blast radius. On a correctly configured project, that means: precisely the rows and operations your policies intend, and nothing else. That's why shipping the key in a client bundle is documented, supported design — Supabase lists "web page, mobile app, GitHub actions, CLIs, source code" among acceptable placements for publishable keys.
The condition: RLS must actually enforce everywhere
Postgres applies Row Level Security only where it has been enabled, and only as your policies specify. Supabase's docs state the requirements directly: enable RLS on all tables, review policies granted to anon/authenticated regularly, and don't alter the built-in roles (Security considerations). Each requirement exists because omitting it converts the public key into broader access. The three canonical erosion patterns:
1. A table without RLS enabled.
create table public.profiles (
id uuid primary key default gen_random_uuid(),
email text,
phone text
);
-- No `enable row level security` was ever run.
-- Every `anon` request through the Data API reads and writes ALL rows,
-- subject only to schema-level GRANTs.
RLS is opt-in per table. One forgotten ALTER TABLE … ENABLE ROW LEVEL SECURITY and that table is fully public through the ordinary API endpoint — no exploit required, just GET /rest/v1/profiles.
2. A policy more generous than intended.
alter table public.documents enable row level security;
create policy "anyone can read" on public.documents
for select using (true); -- literally everyone, forever
Enabled-but-permissive is subtler because dashboards show a green "RLS enabled" state. Policies written hastily during prototyping — using (true) on select, or update policies without with check — remain after prototype features ship. The using (true) select policy makes the table world-readable via the public key; a missing with check on updates lets authenticated users write rows they cannot see.
3. Exposure paths that sidestep table RLS entirely. Security-definer views executing with owner privileges, functions leaking more than their return type implies, and over-granted schema privileges each bypass per-table policies. These are the findings Supabase's Security Advisor surfaces in the dashboard — worth reviewing before dismissing anything.
None of these are exotic misconfigurations. They are the default drift of a codebase where tables accumulate faster than policy discipline — which describes nearly every fast-moving project, and describes especially projects assembled quickly with AI assistance, where the assistant creates tables eagerly and policies only when asked.
Service-role: the game-over tier
The secret/service_role tier bypasses the entire apparatus. Per the docs, secret keys authorize access via the built-in service_role Postgres role, which "has full access to your project's data" and carries the BYPASSRLS attribute — every policy you wrote simply doesn't apply (What secret keys allow).
A disclosed service-role credential therefore equals: full read and write of every table, storage objects per its own bucket policies, and the ability to act as any user context — with no RLS between the holder and anything. Treat discovery of one outside your server environment as a full-database disclosure event, not a key-hygiene incident. Consequences stack: confidential records read (unrecoverable by rotation), data modified or exfiltrated quietly, and — with the legacy JWT — no expiry to wait out.
One generational difference matters for detection and defense: the newer sb_secret_ keys refuse requests presenting browser User-Agents outright (always HTTP 401 per the docs), while the legacy service_role JWT imposes no such check. The check raises the bar modestly — non-browser tooling sails past it — so it changes nothing about treating either as game-over when found in a bundle.
Test the condition; don't assume it
Because the safety argument rests on runtime enforcement, the honest verification is adversarial testing: connect as the public would and attempt what should be denied.
Direct SQL-level probe (run with the anon role's privileges, e.g., via a read-only connection string or PostgREST):
set local role anon;
-- Should ERROR for every private table:
select count(*) from public.profiles;
select count(*) from public.documents;
-- Probe the write path too:
insert into public.documents default values;
Every statement that succeeds instead of erroring marks either missing RLS or a policy looser than intended. Automate the probe per migration so new tables can't silently skip it.
Tooling check: RowShield (rowshield.com) exists precisely for this — it tests Supabase Row Level Security configurations and reports where the anon/authenticated boundary leaks, complementing Supabase's built-in Security Advisor. For a team shipping Supabase-backed apps continuously, an RLS test belongs in the same category as the secret scan: cheap, automated, and aimed at the gap between intended and enforced access.
Artifact-level probe: confirm which generation of keys your deployed bundle carries — the legacy JWT and the sb_secret_… format look nothing alike, and both must fail the scan (what belongs in bundles). Finding a service-role JWT in client JavaScript is the single most severe finding class KeyDrift reports.
Response when the wrong tier leaks
Sequence matters, and Supabase's own docs supply the rotation procedure (rotating secret keys):
- Fix the root cause first if compromise is suspected — the docs explicitly advise remediating the underlying suspicion before rotating, so the replacement isn't exposed the same way.
- Rotate: create a new secret key in Dashboard → Settings → API Keys, migrate consumers to it, then delete the compromised one. Note the docs' warning: deleting a secret key is irreversible.
- Legacy
service_roleJWT: replace it with a new-generation secret key outright, per the docs' recommendation, rather than minting another eternal JWT. - Assume data was read. Unlike a leaked Stripe restricted key, a service-role leak gives no per-resource logs to bound the damage — review Postgres logs and audit whatever your logging retains, and scope notification obligations to the full dataset, not a slice.
- Re-run the RLS probes from the previous section afterward: incidents are when permissive policies get noticed, and the cleanup should leave the policies stricter than the incident found them.
The deeper fix — per-component secret keys so a leaked integration key compromises one integration, not the project — is documented as best practice by Supabase and generalized in the rotation playbook.
Where the tiers belong, mechanically
Placement rules compress to one sentence each:
- Publishable/anon: wherever the app runs, including bundles — conditional on the RLS tests passing continuously.
- Secret/service_role: server environments only. Never in
NEXT_PUBLIC_*/VITE_*variables, never in client-imported modules — the framework-prefix mechanisms that inline values into bundles are covered in the public-env guide, and they will happily publish a secret key given the chance. - Edge Functions caveat: per the docs, Edge Function JWT verification supports only the legacy
anon/service_roleJWTs today; new-format keys require--no-verify-jwtplus your own authorization check inside the function. Plan accordingly when migrating.
Beyond tables: storage, functions, and realtime
The RLS conversation centers on database tables, but project surfaces extend wider — and each carries its own relationship to the key tiers:
- Storage objects enforce their own policies per bucket. A public upload bucket with permissive policies exposes objects to anyone holding the publishable key — the same conditional-publicity logic as tables, applied to files. Audit bucket policies alongside table policies; they drift independently.
- Edge Functions hold their own secrets (environment-bound), but the docs' compatibility note matters for key migration: JWT verification supports legacy
anon/service_roletokens only today, with new-format keys requiring--no-verify-jwtplus in-function authorization checks. Migrating keys without adjusting function verification is a working-code-goes-dark surprise waiting on deploy day. - Realtime channels authorize
postgres_changessubscriptions through the same Postgres policies — RLS quality propagates to live streams automatically, which is elegant and means policy erosion leaks data through websocket subscriptions exactly as it does through REST queries. - Auth administration (user impersonation, deletion, link generation) rides on service-tier access via the Auth admin API — one more reason the elevated tier is game-over rather than merely powerful.
The composite picture strengthens the article's core claim: nearly every Supabase surface funnels authorization decisions back to Postgres roles and policies, which is why testing those policies continuously — the probe queries, automated per migration — covers more attack surface than any per-feature review. And why artifact-level scanning matters doubly here: finding either generation of elevated key in client code compromises every surface at once, tables included.
A policy review cadence that holds. The safety condition erodes through accumulation, so the countermeasure is cadence rather than heroics. Three recurring checks keep policies aligned with intent:
Per migration (automated). Every migration adding or altering tables runs the anon-role probe suite in CI against a disposable branch database — statements that should fail get asserted as failing (the probes). This converts policy review from periodic archaeology into a build gate, catching using (true) drafts before they meet production data.
Monthly (dashboard pass). Supabase's Security Advisor findings reviewed item by item — dismissed findings documented with reasons, since dismissal patterns are themselves diagnostic. The advisor's checks cover the built-in-role alterations and function exposure classes that hand review misses.
Quarterly (adversarial pass). The full manual exercise: enumerate every table, view, and function reachable by anon/authenticated; attempt reads and writes a hostile client would attempt; verify storage bucket policies independently. Thirty minutes per project, scheduled like any other audit — this is also when bucket policies and Edge Function authorization get their look (the surfaces beyond tables).
The cadence mirrors a pattern running through this entire series: continuous verification beats annual assurance wherever failure accumulates silently. RLS misconfiguration is precisely such a failure — nothing breaks, everything still works, until the day someone points a publishable-key-shaped query at a table whose policy never shipped.
Reading a Supabase key finding
Scanner hits on this provider triage faster with the format-to-meaning mapping internalized:
| Finding shape | What it is | Severity logic |
|---|---|---|
sb_publishable_… / anon JWT (role: anon) | Public-tier identity key | Expected in client code — verify RLS instead (the condition) |
JWT with role: authenticated | A user's session token | Treated as public by scanners for good reason: it authorizes one user, briefly |
| Unknown/expired JWT | Unresolvable or stale payload | Low confidence — investigate manually |
sb_secret_… | Elevated, bypasses everything | Critical — rotate now |
Legacy JWT with role: service_role | Elevated, eternal, no browser check | Critical — same response, plus migrate to modern keys |
The decode step is what makes this table work: base64-decoding a JWT's payload (locally, never pasting production tokens into web decoders) reveals the role claim that prefixes don't carry. Scanner tooling does this structurally — which is why Supabase detection can be simultaneously low-noise about legitimate publishable keys and unambiguous about service-role material (rule design).
One inventory habit closes the loop: because both generations stay valid until explicitly disabled, projects mid-migration carry four live credentials. Findings inventories should expect all four shapes, and remediation includes retiring whichever generation each finding represents — not just the one the scanner named.
FAQ
Is the anon key "a secret"? No — with the standing caveat that its safety is a claim about your policies, not the key. Publishing it is supported design; verifying RLS continuously is part of the same design, not optional diligence.
How do I tell a legacy service-role JWT from an anon JWT in a bundle? Decode it (they're base64 JWTs): the role claim reads service_role or anon. Synthetic examples aside, never paste production tokens into decoders on shared machines — masked prefixes from a scanner report give the answer safely.
RLS is enabled on all our tables. Are we done? Only if every policy matches intent and no definer views/functions bypass them — claims that require testing, not inspection. The green "RLS enabled" badge has coexisted with using (true) policies in countless projects; the probe queries above take minutes.
Should we disable the legacy keys once migrated? Yes — the docs describe disabling them in Dashboard → Settings → API Keys as a deliberate separate step, and leaving them live doubles the credential surface for exactly the highest-tier key. Inventory, migrate, disable, in that order.
Half of Supabase risk is key placement; the other half is whether your policies actually fire. KeyDrift's free scan tells you which keys your deployed bundle is serving — no signup, minutes to an answer.