Row-Level Security in Postgres: the complete evaluation model
How Postgres actually combines policies: permissive OR, restrictive AND, USING vs WITH CHECK per command, who bypasses RLS, and what a denied row does.
Every Supabase table's security comes down to one question: given this caller, this command, and these policies, which rows does Postgres allow? This article answers it mechanically, for developers who need to reason about policies instead of guessing at them.
Postgres applies row-level security at the moment a query touches a table. It collects the policies that match the current role and the current command, turns them into row predicates, and evaluates them alongside your query's own conditions. Nothing about this is mysterious once you have the model — and almost every RLS surprise traces back to missing one specific piece of it: how policies combine, what each clause governs, or who skips evaluation entirely.
This is the reference piece for that model. Every other article on this site that talks about policies stands on what follows, and the rule documentation assumes you can read a policy the way this article teaches.
The unit of protection is table, command, role
A policy is not a property of a table in general. It is a predicate attached to three things at once:
- a table — the object whose rows are being tested;
- a command —
SELECT,INSERT,UPDATE,DELETE, orALL; - a role — the database role(s) the policy applies to, via the
TOclause.
When a query arrives, Postgres selects every policy whose table matches, whose command covers the operation, and whose role list includes the calling role (or PUBLIC, which matches everyone). Only that selected set participates in evaluation. A perfect SELECT policy does nothing for an UPDATE; a policy written TO service_role never fires for the anon role.
Two more facts complete the setup:
- Enabling RLS switches the table to default-deny. Once
ENABLE ROW LEVEL SECURITYis on, a role with no matching policy sees zero rows and may write zero rows. The PostgreSQL documentation is explicit: if no policy applies, the row is simply not accessible. This is why "RLS enabled, no policies yet" produces a mysteriously empty app rather than an open one — the failure mode is silence, not exposure. - Privileges and policies are separate gates. SQL privileges (
GRANT SELECT, and so on) are checked before row security. RLS narrows rows; it never widens a missing grant. Both gates must pass; the RLS glossary keeps the two layers' vocabulary straight.
The two clauses, mapped to the four commands
Each policy carries up to two boolean expressions:
USINGis evaluated against rows that already exist. It decides which rows are visible for reading, and which existing rows anUPDATEorDELETEmay target.WITH CHECKis evaluated against a row the caller is trying to produce — an inserted row, or the post-update version of a row. It decides whether that new state is acceptable.
Not every command accepts both clauses, and the mapping is worth memorizing outright:
| Command | USING governs | WITH CHECK governs | Failure behavior |
|---|---|---|---|
SELECT | Which rows are returned | Not permitted | Rows silently filtered out |
INSERT | Not permitted | Whether the new row is accepted | Error 42501: "new row violates row-level security policy" |
UPDATE | Which existing rows may be changed | Whether the resulting row is acceptable (falls back to USING if omitted) | Silent zero-match on USING; error on WITH CHECK |
DELETE | Which rows may be removed | Not permitted | Silently affects fewer rows |
ALL | Applies USING to all reads and target selection | Applies to writes; falls back to USING if omitted | Mixed, per command |
Three consequences of this table cause most real-world incidents:
An UPDATE policy with only USING lets rows escape. The caller can modify any row the USING clause admits, into anything at all — including changing owner_id to another user. Without WITH CHECK, Postgres constrains the before-state but not the after-state. The row walks out of the caller's scope in a single successful statement.
An INSERT succeeds even when the writer cannot read the result. Inserts are judged only by WITH CHECK. If your SELECT policy is stricter, a caller can create a row attributed to someone else and never see it again. From the application side this looks like a lost write; from the security side it is a planted one.
FOR ALL is four policies in one coat. Convenient, but a FOR ALL policy with only USING leaves every write path leaning on that same expression as its check — and if the author wrote no expression thinking about writes at all, the check inherits whatever the read logic happened to imply. The site keeps a dedicated rule for write policies with no check: see missing WITH CHECK.
How multiple policies combine
This is the piece that most often goes untaught, and it changes how you read every policy dump.
Multiple permissive policies (the default kind) combine with logical OR. If any matching permissive policy admits a row, the row is admitted. Two SELECT policies do not intersect — they union. Adding a policy can only widen access, never narrow it.
Multiple restrictive policies combine with logical AND — with everything. A RESTRICTIVE policy never grants access on its own; it can only remove rows that the permissive layer already admitted. Declare one with as restrictive, and it is ANDed onto the result of the permissive OR.
So the effective read condition for a role is always:
(P1 OR P2 OR ... Pn) AND (R1) AND (R2) AND ...
where P are permissive and R are restrictive policies matching that command and role. The CREATE POLICY documentation states both aggregation rules directly.
A complete demonstration
Everything above fits in a runnable block. This schema — a small fabricated example project with documents and workspace membership — is used throughout RowShield articles. Run it on any Supabase project (or local Postgres with an auth.uid() equivalent) and the results below are deterministic.
create table documents (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null,
title text not null,
owner_id uuid not null,
confidential boolean not null default false
);
create table workspace_members (
workspace_id uuid not null,
user_id uuid not null,
primary key (workspace_id, user_id)
);
alter table documents enable row level security;
-- Permissive policy 1: owners see their own documents.
create policy "documents_select_own"
on documents for select
to authenticated
using ((select auth.uid()) = owner_id);
-- Permissive policy 2: members of a workspace see that workspace's documents.
create policy "documents_select_workspace"
on documents for select
to authenticated
using (exists (
select 1 from workspace_members m
where m.workspace_id = documents.workspace_id
and m.user_id = (select auth.uid())
));
-- Fixtures: Alice owns alpha; bravo belongs to a workspace she joined; charlie is neither.
insert into documents (id, workspace_id, title, owner_id, confidential) values
('dddddddd-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-00000000000a', 'alpha', '11111111-1111-1111-1111-111111111111', false),
('dddddddd-0000-0000-0000-000000000002', 'aaaaaaaa-0000-0000-0000-00000000000b', 'bravo', '22222222-2222-2222-2222-222222222222', false),
('dddddddd-0000-0000-0000-000000000003', 'aaaaaaaa-0000-0000-0000-00000000000c', 'charlie', '22222222-2222-2222-2222-222222222222', false);
insert into workspace_members values
('aaaaaaaa-0000-0000-0000-00000000000b', '11111111-1111-1111-1111-111111111111');
Query as Alice (impersonation mechanics are covered in our testing guide):
begin;
set local role authenticated;
select set_config('request.jwt.claims',
json_build_object('sub', '11111111-1111-1111-1111-111111111111')::text, true);
select title from documents order by title;
rollback;
Result: alpha, bravo. Two permissive policies, OR'ed together, each contributing rows. Now make bravo confidential and add a restrictive policy:
update documents set confidential = true where title = 'bravo';
create policy "documents_confidential_owner_only"
on documents
as restrictive
for select
to authenticated
using (confidential = false or (select auth.uid()) = owner_id);
Re-run the same impersonation block (with the same Alice sub). Result: alpha, and nothing else. The permissive union did not shrink — the restrictive policy intersected it. bravo was admitted by the workspace policy but rejected by the restrictive gate; charlie was rejected by both permissive policies and never reached the restrictive one at all.
| Row | Admitted by permissive OR | Passes restrictive | Visible to Alice |
|---|---|---|---|
| alpha | Yes (ownership policy) | Yes | Yes |
| bravo | Yes (workspace policy) | No — confidential, not owner | No |
| charlie | No | (not evaluated for admission) | No |
The practical reading habit this buys you: when you see several permissive policies on one table, read each one independently as "this alone opens these rows," because that is exactly how Postgres will evaluate them. A policy that looks narrow in isolation can be redundant, harmless — or the wide one hiding behind two careful-looking siblings. We dig into that accumulation pattern in policy sprawl.
Who bypasses row security entirely
Some callers skip policy evaluation altogether. Knowing this list cold prevents both false confidence and wasted debugging:
| Caller or context | Bypasses policies? | Detail |
|---|---|---|
| Superuser roles | Always | Evaluation never happens. |
Roles with the BYPASSRLS attribute | Always | Set per role; see role attributes. Supabase's service_role carries it. |
| The table owner | By default | Owners skip RLS unless the table also sets FORCE ROW LEVEL SECURITY. |
SECURITY DEFINER functions | Depends on the function owner | The function runs as its owner; if that owner bypasses, so does the function body. |
| Views over the table | Historically yes | Pre-Postgres-15 views execute with the view owner's privileges. Postgres 15+ supports security_invoker = true views; see the Supabase views guide. |
TRUNCATE | Always | Row security has no TRUNCATE concept; the command is governed by table privilege alone. |
| Foreign-key enforcement | Always | Constraint checks run with row security disabled so referential integrity holds. |
The owner row deserves emphasis because it explains a classic confusion: you test through the API as anon and see three rows, then open the SQL editor — which connects as the owning postgres role — and see the full table. Nothing is broken. You were two different callers with two different relationships to the same policy set. When backend jobs legitimately need owner-grade access to a table that humans also own, FORCE ROW LEVEL SECURITY makes the owner subject to policies too; otherwise the owner remains a standing exception you should know about rather than discover.
What a denied row actually does
Row security failures come in two shapes, and telling them apart matters when you are debugging:
| Situation | Observable result | What the caller learns |
|---|---|---|
SELECT fails USING | Row absent from results; no error | Nothing — indistinguishable from the row not existing |
DELETE/UPDATE target fails USING | Zero rows affected; no error | Nothing |
INSERT fails WITH CHECK | Error 42501 | That the write was rejected, not why |
UPDATE result fails WITH CHECK | Error 42501 | That the resulting shape was rejected |
Silent filtering is the design choice that makes RLS pleasant to build with and hard to debug: an overly strict policy produces an empty UI instead of a stack trace, and an attacker probing IDs gets no oracle from reads. The loud case is equally specific: new row violates row-level security policy for table "documents" means a WITH CHECK clause rejected the row as written — the fix conversation is about the write path, not the read path.
Neither behavior leaks the contents of denied rows through the error channel. What can leak is inference at the application layer — response times, distinct error messages elsewhere in your stack — which is a reason to keep policies simple enough to reason about, not a property of Postgres you must defend around.
Reading pg_policies back
Postgres exposes every policy through the pg_policies view, and it maps one-to-one with the model above:
select tablename, policyname, permissive, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd, policyname;
Read each column as a field of the evaluation record:
permissive—PERMISSIVEjoins the OR;RESTRICTIVEjoins the AND.roles— theTOlist; a role not listed never evaluates this policy.cmd— which column of the command table above this policy occupies.qual— theUSINGexpression, as stored text.with_check— theWITH CHECKexpression; null here on anINSERT,UPDATE, orALLpolicy is the signature of an unconstrained write.
Sorting by tablename, cmd puts every policy competing in the same OR group next to each other, which is how you want to read them. Any table where that grouping shows three or more permissive policies for one command deserves a slow second read — you are looking at the union that actually defines your access, not any individual line. Our help center documents this and the other catalog checks in the findings reference.
The evaluation model in eight sentences
Condensed, the whole machine:
- Privileges first: no grant, no query — RLS is never reached.
- Collect policies matching (table, command, calling role or
PUBLIC). - Evaluate every permissive policy's predicate; OR the results.
- AND the permissive result with every restrictive policy's predicate.
- For reads and delete/update targeting, apply the combined predicate to existing rows via
USING. - For inserts and update results, apply it to the new row via
WITH CHECK. - Failed reads filter silently; failed writes raise
42501. - Owners, superusers, and
BYPASSRLSroles skip steps 2–7 unlessFORCEintervenes.
Keep that list beside any policy review. Most policy bugs are one violated sentence from this list, and the rest of this site — the fundamentals hub, the rule pages, and the drift guides — exists because keeping a schema in compliance with eight sentences is an operational task, not a one-time act.
Common questions
Does ENABLE ROW LEVEL SECURITY lock out the table owner?
No. The owner continues to bypass policies until you add FORCE ROW LEVEL SECURITY to the table. Enabling RLS changes behavior for every non-owner role immediately — with zero policies, that means they now see nothing.
Can a RESTRICTIVE policy grant access by itself?
Never. Restrictive policies can only subtract rows from what permissive policies already admit. A table whose only policy is restrictive behaves like a table with no policies: default-deny for everyone the owner rules apply to.
What does a FOR ALL policy with only USING actually cover?
All four commands, with USING doubling as the write check wherever WITH CHECK is absent. That makes it valid but dangerous: one expression now simultaneously decides readability and write legality, and reviewers tend to reason about it as if it were only a read policy.
Do policies apply to TRUNCATE?
They do not. TRUNCATE bypasses row security entirely and is controlled solely by table-level privilege. In a Supabase deployment the client-facing roles effectively cannot reach it, but backend scripts and SECURITY DEFINER code can — keep it out of any path influenced by user input.
My INSERT fails with "new row violates row-level security policy". Is the read policy wrong?
Almost certainly not. That error is produced only by a WITH CHECK clause rejecting the row as written. Look at your INSERT or ALL policies — or at an UPDATE policy whose missing WITH CHECK fell back to its USING expression.
Run the free scan on your own Supabase project: paste your app's URL and see what the public surface exposes, with remediation SQL proposed for each finding.
RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.