RowShield
All posts

How Supabase authorization actually flows

From sign-in to row visibility: how a JWT becomes a database role, what auth.uid() reads, and where the authorization chain breaks in practice.

RowShield12 min read

Every Supabase permission decision is the end of a chain: sign-in issues a token, the token names a database role, the role meets your policies, and rows appear or don't. This article walks that chain end to end for developers who want to reason about authorization rather than poke at it.

When someone asks "why can't the client see this table?" or worse, "why can they?", the answer is never one thing. It is a pipeline: an authentication server that mints tokens, an API gateway that verifies them and switches database roles, helper functions that read claims into policies, and the policy evaluation machinery itself. Each stage has its own failure modes, and each stage is invisible from the dashboard. This piece traces the whole chain once, carefully, so every other authorization question has somewhere solid to stand.

Two keys, one role claim

A Supabase project hands every client two long strings: the public anon key and the service-role key. Neither is a password in the traditional sense — both are JWTs signed by the project, and both carry a claim named role that names a real Postgres role:

KeyPayload role claimWho holds it
anon keyanonEveryone — ships in your bundle, visible in network tabs
service_role keyservice_roleYour servers only

The anon key being public is a design decision, not a leak: the anon key is public by design, and the security model assumes anyone can hold it. What protects data is therefore never secrecy of the key — it is what the named role is allowed to see once policies evaluate. When a visitor without an account makes a request, the API acts as anon. When a signed-in user makes one, the request carries their token with role: authenticated and their identity inside it. The service-role key exists for trusted server contexts and is dangerous precisely because its role bypasses policies entirely — the boundary line between these roles deserves its own deep read in service_role versus anon.

Supabase has been transitioning toward a new key model (publishable and secret keys) that preserves exactly this split under different names; the flow described here is unchanged, and the mapping is covered in the new publishable keys model.

One property of this design is worth pausing on: the chain fails closed. A token naming a role that does not exist, a signature that does not verify, an expired session — each stops the request before any data is consulted, rather than degrading to a safer-looking fallback. There is no configuration in which an unverifiable identity gets some rows. That asymmetry between authentication failures (loud, immediate) and policy failures (silent filtering) is why the two halves of this pipeline need different debugging habits, and why incidents cluster at the quiet end.

From sign-in to session token

Sign-in is the token factory. When a user authenticates — email, OAuth provider, magic link — Supabase Auth verifies their identity out-of-band and returns a JWT whose payload encodes the session:

{
  "iss": "https://YOUR-PROJECT.supabase.co/auth/v1",
  "sub": "11111111-1111-1111-1111-111111111111",
  "role": "authenticated",
  "email": "alice@example.com",
  "aud": "authenticated",
  "exp": 1790000000,
  "app_metadata": { "provider": "email" }
}

Four claims do almost all the work:

  • sub — the user's UUID, stable across sessions. This is the value auth.uid() returns, and it is the anchor of nearly every ownership policy you will write.
  • role — the database role the API gateway will assume. Auth-issued tokens say authenticated; this field is the pivot of the whole chain.
  • exp — expiry. Expired tokens fail verification outright; there is no grace period in which policies apply partially.
  • app_metadata — admin-controlled attributes. Unlike user_metadata, clients cannot edit it, which makes it the right home for entitlement flags referenced in policies.

Projects can add custom claims at token issuance using the custom access token hook, documented in Supabase's auth hooks guide — the mechanism that lets a policy test, say, a plan tier without joining a billing table per row.

The critical mental model: the token is the authorization context. The database never queries a users table to decide who you are mid-request. Whatever the signed payload says at verification time is the entire truth for that request.

The request path through PostgREST

Client libraries obscure this, so strip them away. A raw request against your own project carries two headers:

curl "https://YOUR-PROJECT.supabase.co/rest/v1/documents" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Authorization: Bearer USER_JWT"

What the API layer then does is mechanical, and worth knowing step by step:

  1. Verify the signature against the project's signing material. Forged or expired tokens stop here with a 401.
  2. Read the role claim. This names a Postgres role that must exist in the database — anon or authenticated for normal traffic.
  3. Switch to that role for the duration of the request. The incoming connection runs as a proxy role whose only job is assuming others; after verification, everything executes as authenticated, not as some superuser who must be constrained later. Role switching is standard PostgreSQL behavior, governed by the SET ROLE semantics.
  4. Publish the claims into session state (request.jwt.claims), where helper functions read them.
  5. Run your SQL. Privileges are checked against the assumed role; row-level security evaluates that role's policies; results stream back.

Step 3 is the one people underestimate. Because execution genuinely happens as authenticated, the exact policies that govern your API requests can be exercised directly in SQL — impersonate the role, supply the claims, run the query. RowShield's testing pillar is built on precisely that equivalence, and it means the API adds no authorization logic of its own: the database boundary is the whole boundary.

The scoping of step 3 explains why shared infrastructure stays safe. Requests are served from a connection pool, but the role switch happens per transaction, unwound when the transaction ends — the pooled connection itself holds no user identity between requests. Two requests from different users can share the same physical connection microseconds apart, each seeing only its own caller's policies, because identity lives in transaction-local state, not in the connection. This is also why long-lived application code should never hold a "logged in" database session across requests: the design deliberately re-establishes identity every time, and anything that caches it outside a transaction has stepped off the supported path.

What auth.uid() actually reads

Almost every policy you will read this year contains auth.uid(). Its job is small: return the sub claim of the current request's token, as a UUID. Conceptually:

-- Equivalent in spirit to what auth.uid() computes;
-- the real function lives in the auth schema of your project.
select nullif(
  current_setting('request.jwt.claims', true)::jsonb ->> 'sub',
  ''
)::uuid;

Three consequences follow from this definition, and they explain a lot of behavior:

No session claims means no identity. A backend script connecting directly to Postgres sets no JWT context, so auth.uid() returns null, ownership comparisons evaluate to null — not false — and the policy filters everything. Direct database connections do not inherit API identity; they need explicit roles.

Identity is claim-based, not lookup-based. Policies never join against auth.users to establish who you are. That is why the same policy works identically whether the user exists in a profile table or not, and why deleting a user does not retroactively re-evaluate their old rows' visibility — the rows simply stop matching anyone's future tokens.

It is cheap when wrapped. Wrapping the call as (select auth.uid()) lets Postgres evaluate it once per statement instead of per row — same value, radically different plan shape; the mechanics have their own rule page.

The roles underneath your project

Zooming out, a project provisions a small cast of database roles. You will never connect as most of them directly, but knowing the map prevents category errors:

RolePurposePolicy relationship
anonIdentity-less HTTP callersSubject to all policies; typically near-zero grants of trust
authenticatedSigned-in users via their JWTsSubject to all policies; the default target of an application's policy set
service_roleTrusted server codeCarries BYPASSRLS; sees every row, writes anywhere
authenticatorConnection proxyCannot touch tables; only switches into the roles above per request
postgresDashboard SQL editor, migrationsTable owner; bypasses policies unless tables are FORCEd
supabase_adminPlatform operationsEffectively superuser; none of your code runs as this

Two rows repay staring at. authenticator explains how one connection pool serves every tenant and every anonymous visitor safely: the proxy holds no data privileges itself, so nothing leaks sideways between requests. And postgres explains the recurring confusion where the SQL editor shows more rows than the app — ownership bypasses row security, as detailed in the evaluation model reference; it is a feature for migrations and a standing exception everywhere else.

A note on where tokens come from completes the map for teams with nonstandard identity setups. Supabase Auth mints the tokens in the flow above, but projects can also accept identities from third-party providers — the token is then issued by Supabase after the external verification, so the database-side contract is unchanged. What changes is your review surface: whichever system mints tokens controls the claims policies will trust, and its configuration becomes part of your authorization surface. The integration specifics and their policy implications are covered in RLS with third-party auth.

Where the chain breaks in practice

Each stage fails differently, and recognizing the stage from the symptom halves debugging time:

SymptomBroken stageUsual cause
401 on every requestToken verificationExpired token, wrong project's key, malformed header
Empty result arrays, no errorPolicy evaluationMissing or too-strict USING clause; default deny doing its job
Inserts fail with 42501WITH CHECK evaluationNew row doesn't satisfy the write condition
User sees another user's rowsPolicy targeting or combinationPermissive union, using (true) placeholder, wrong column compared
Everything visible regardless of loginRole assumptionService-role key used in client-side code
Works in editor, differs in appOwnership bypassComparing owner-grade session to API-grade session

That fifth row is the one with incident potential, and it is a deployment mistake rather than a policy mistake: the chain is only as strong as the least privileged key you shipped. Detection and containment patterns live in the service role exposure rule.

The sixth row is worth internalizing as a team habit: when the SQL editor and the application disagree about a table's contents, neither is lying. They are different callers in different stages of this very chain.

Tracing one request end to end

Assemble the pieces with a single walk-through. Alice signs in; her client receives the payload shown earlier (sub ending …aaa, role: authenticated). Her app requests her documents:

curl "https://YOUR-PROJECT.supabase.co/rest/v1/documents?select=title" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ALICE_JWT"

Downstream: signature verified against the project's signing keys; role: authenticated selected; the pooled connection assumes authenticated for this transaction; claims published to session state; the generated query executes with Alice as current_user. On the documents table, her arrival triggers exactly one evaluation path — the select policies targeted at authenticated:

using (
  (select auth.uid()) = owner_id
  or exists (
    select 1 from workspace_members m
    where m.workspace_id = documents.workspace_id
      and m.user_id = (select auth.uid())
  )
)

auth.uid() resolves to 11111111-…; ownership matches her own rows, membership matches her workspace's shared rows; everything else silently vanishes from the result set — not hidden, absent, indistinguishable from never existing. The response contains titles she may know about and nothing else.

Now replay the identical request with only the anon key in both headers. Signature still verifies — the anon key is legitimately signed. But the role claim says anon, no select policy targets anon on this table, default deny applies, and the response is an empty array. Same table, same endpoint, same minute; the entire difference lives in one claim and the policies keyed to it. If you want to go one level deeper and prove these outcomes yourself before trusting them, the impersonation harness in testing RLS replays both cases inside plain SQL.

Common questions

Can a user edit their token's role claim to service_role?

They can edit the payload all they like; they cannot re-sign it. Verification checks the signature against the project's signing material before the claim is trusted, so tampering yields rejection, not elevation. The practical corollary: protect signing keys and never accept tokens minted outside your project's auth server for API decisions.

What is the difference between the apikey header and Authorization header?

Tradition plus flexibility. The API layer accepts the project key in apikey alone (acting as anon) or additionally a user JWT in Authorization: Bearer, upgrading the effective role to authenticated. Some stacks send the same value in both; the user JWT always wins for role selection when present and valid.

What happens when a token expires mid-session?

Nothing dramatic — the next request simply fails verification with a 401, and the client library refreshes transparently using the session's refresh token. There is no window where an expired token keeps working, because verification happens per request, not per login.

Why do my server logs show queries running as postgres?

Anything connecting directly to Postgres — migrations, dashboards, many background jobs — connects as the owning role, which bypasses policies entirely. Logs reflecting that are accurate. The takeaway is directional: any code path that must respect user isolation should travel through the API surface as a claimed role, or explicitly set role and claims itself.

Where do refresh tokens fit in this chain?

They belong entirely to the authentication stage. A refresh token exchanges for a new access token when the current one nears expiry; it never travels to Postgres and plays no role in policy evaluation. From the database's perspective every request begins fresh: verify token, assume role, evaluate policies. That per-request amnesia is what makes the chain auditable — there is no session state server-side whose drift could quietly widen access.


See the chain's output for your own project: run the free scan — paste your app URL and learn exactly what an anonymous caller can reach today, with proposed fixes for everything found.

RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.