1 · The authorization chain
The RLS Field Guide · 5 min read
The question this chapter answers: when a request reaches your database, who does Postgres think is asking?
Before a single policy runs, three things have already happened: your client presented credentials, PostgREST translated them into a Postgres role and a set of claims, and Postgres checked that the role holds grants on the table. Row-level security sits at the end of this chain, not the beginning. Understanding the chain explains almost every puzzling RLS result — starting with the one you will meet first, an empty array where you expected rows.
The path a request takes
A Supabase client talks to Postgres through PostgREST. It never opens a session as itself. Each request carries:
- an API key identifying the project surface it may use, and
- usually a JWT in the
Authorization: Bearerheader, issued by Supabase Auth to a signed-in user.
PostgREST reads the JWT's role claim, switches the database session to that Postgres role, and stores the token's claims where SQL can read them. A decoded payload looks like this (synthetic values):
{
"sub": "c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3",
"role": "authenticated",
"aud": "authenticated",
"exp": 1790000000
}
The sub claim — the user's id — is what the helper function auth.uid() returns inside policies. The role claim selects one of three predefined Postgres roles:
| Role | Who gets it | Bypasses RLS |
|---|---|---|
anon | Requests with no valid user token | No |
authenticated | Requests carrying a valid user JWT | No |
service_role | Trusted server code holding the secret key | Yes |
The Supabase roles guide documents all of them; two more matter operationally: authenticator, which exists so PostgREST can perform the role switch, and postgres, the administrative role your SQL Editor connects as.
One distinction worth fixing early, because it confuses everyone once: the anon role is not the same thing as an anonymous user. An anonymous sign-in produces a real user with a real id whose requests run as authenticated. The anon role means "no user at all." (Supabase docs)
Grants come before policies
Postgres performs two checks, in order:
- Grants — does this role hold privilege (
select,insert,update,delete) on this table? If not, the query fails immediately with error42501. - Policies — if RLS is enabled, which rows does the policy let this operation touch?
This ordering has a practical consequence that saves hours of debugging: when a request fails with 42501 and you were sure a policy allowed it, check the grant first. The policy never ran. The same page of the Supabase RLS guide makes the point bluntly: adding policies does not remove existing grants, and a table in an exposed schema without RLS is readable and writable by any role holding a grant on it.
Where row-level security sits
Once grants pass, RLS acts like an automatically appended where clause per command. For reads it filters rows out silently. For writes it rejects rows that violate a with check condition. Two properties define its character:
- Deny by default. With RLS enabled and no policies, every non-bypassing role sees nothing. An empty result is often the security working correctly.
- Ownership bypasses it — unless forced. A table's owner is exempt from its policies unless the table was altered with
force row level security. On Supabase, objects created through the dashboard or SQL Editor are typically owned bypostgres, which is why you see everything in the editor while your users see only their rows.
That last line is worth internalizing now, because Chapter 5 turns it into an audit rule: the view from the SQL Editor proves nothing about what a user experiences.
Ask your database who it thinks you are
Run this in the SQL Editor of any project:
select rolname, rolcanlogin, rolbypassrls
from pg_roles
where rolname in ('anon', 'authenticated', 'service_role', 'authenticator')
order by rolname;
Expect four rows. Only service_role shows rolbypassrls = t; that single boolean is why server-side keys must never ship to a client. Then ask about yourself:
select current_user as session_role,
coalesce(nullif(current_setting('request.jwt.claim.sub', true), ''), '(no user)') as caller_sub;
In the editor this reports postgres and (no user) — the identity combination that bypasses policies by ownership and lacks no grant. Your running application reports authenticated and a real user id. Same database, same minute, completely different authorization outcomes. Every proof in Chapter 6 works by deliberately recreating the second combination inside a transaction.
Why the chain matters for drift
Each link in the chain is set somewhere different: keys live in client bundles and server environments; grants live in migrations (or arrived as defaults); policies live in the catalog; tokens live with users and expire independently of your deploys. Nothing fails loudly when they stop agreeing — a key used more widely than intended simply widens who asks, a missing grant simply blocks, a stale policy simply filters or admits without comment. That quiet quality is the subject of Chapter 4. First you need to read the policies themselves, clause by clause, which is next.
Check on your project
- List every place your anon/publishable key appears. It identifies your project publicly by design — but each appearance marks a surface that relies entirely on your policies.
- Search your server code for the service/secret key. Each hit should be justifiable in one sentence ("nightly aggregation job"). Hits you cannot justify are Chapter 4's service-role creep waiting to happen.
- Run the
pg_rolesquery above and confirmservice_roleis the only role withrolbypassrls. - Pick one table users complain about being "empty" and re-check it as the actual requesting role, not as
postgres. Note how many of your historical "RLS bugs" were grant problems instead. - Find where your project sets table ownership (dashboard-created vs migration-created). You will need this in Chapter 3 to reason about seeding and the owner bypass.