Skip to content
RowShield
All posts

WITH CHECK is the other half

USING governs what a request can read. WITH CHECK governs what it can write. A policy with only the first half lets a user insert rows they will never be allowed to see.

RowShield5 min read
rlspolicies

Two clauses, two directions

Row-level security policies have two conditions, and most of the advice you will read covers one of them.

USING is applied to rows that already exist. It decides which of them a caller may see, and it governs select, and the read half of update and delete.

WITH CHECK is applied to rows as they are being written. It decides whether the resulting row is one this caller is allowed to have created, and it governs insert, and the write half of update.

A policy can have either, both, or — for insert — only WITH CHECK, since there is no existing row to read.

create policy "bookings_select_own"
on public.bookings for select
to authenticated
using ((select auth.uid()) = renter_id);

That policy is complete and correct for reads. It says nothing whatsoever about writes, which is fine, because it is a select policy. The failure happens one step later, when somebody adds writes.

What a missing WITH CHECK permits

Inserting rows owned by someone else

create policy "bookings_insert"
on public.bookings for insert
to authenticated
with check (true);

Written under pressure, because inserts were failing and this made them work. Any signed-in user can now create a booking with any renter_id they like — including somebody else's.

The consequences depend on your product, and they are rarely just "a spurious row". If bookings drive availability, one user can block another's calendar. If they drive invoicing, one user can generate charges against another. If a downstream process trusts the row because it came from the database, the write becomes an input to something that never re-checks it.

curl -s -X POST "https://EXAMPLE-PROJECT.supabase.co/rest/v1/bookings" \
  -H "apikey: ${ANON}" -H "Authorization: Bearer ${USER_A_JWT}" \
  -H "Content-Type: application/json" \
  -d '{"listing_id":"…","renter_id":"<user B id>","total_cents":1000}'

If that returns a row, you have found it. Note the asymmetry that makes this so easy to miss: user A cannot read the row they just created, because the select policy is correct. The insert succeeded, the read returns nothing, and from the application's perspective it looks like the write silently failed.

Updating a row out of your own scope

The subtler case, and the one people are surprised by even after they have understood the first.

create policy "bookings_update_own"
on public.bookings for update
to authenticated
using ((select auth.uid()) = renter_id);

This looks careful. It has an ownership check. But on an update, USING decides which rows you may modify while WITH CHECK decides what they may look like afterwards — and with no WITH CHECK, Postgres does not constrain the result at all.

So a user may take a row they legitimately own and update renter_id to somebody else's identifier. They have handed their own row away, or, more usefully to them, they can move rows into another user's account. One statement, no error.

Why both clauses are needed on update

The rule is worth memorising because it is not intuitive: on update, USING tests the row before, WITH CHECK tests the row after. Protecting only the before-state means a caller can move a row out of their scope but not into it; protecting only the after-state means they can modify rows they never owned as long as the result looks correct. Neither alone is sufficient.

A policy with a USING clause and no WITH CHECK is a lock on the door and an open window.

The complete policy set for a user-owned table

Four policies, one per command, with both clauses where both apply.

alter table public.bookings enable row level security;

create policy "bookings_select_own"
on public.bookings for select
to authenticated
using ((select auth.uid()) = renter_id);

create policy "bookings_insert_own"
on public.bookings for insert
to authenticated
with check ((select auth.uid()) = renter_id);

create policy "bookings_update_own"
on public.bookings for update
to authenticated
using ((select auth.uid()) = renter_id)
with check ((select auth.uid()) = renter_id);

create policy "bookings_delete_own"
on public.bookings for delete
to authenticated
using ((select auth.uid()) = renter_id);

In almost every ownership model the WITH CHECK condition is identical to the USING condition, which is why a generated fix will normally propose exactly that: reuse the read condition as the write condition. Widen it only when writers are genuinely meant to create rows they cannot subsequently read — an append-only audit table, or a submission that becomes invisible to its author on completion. Those cases exist, they are rare, and they should be commented where they occur, because the next reader will assume it was an oversight.

Common questions

Does a FOR ALL policy cover this?

for all applies to every command and accepts both clauses, so a single policy with both using and with check is a legitimate and compact way to express this. The risk is that for all with only a using clause is a very easy thing to write, and it leaves the write side unconstrained across four commands at once rather than one. If you use for all, always supply both clauses.

How do I find tables missing this today?

Query pg_policies for policies whose command permits writing and whose with_check is null.

select tablename, policyname, cmd
from pg_policies
where schemaname = 'public'
  and cmd in ('INSERT', 'UPDATE', 'ALL')
  and with_check is null;

Every row is a policy that permits a write without constraining its result. Some will be deliberate. Most will not be.

Will adding WITH CHECK break my application?

It will break any write that was relying on being unconstrained, which is the point of adding it. Expect failures where your application writes rows on a user's behalf that are not owned by that user — a common and legitimate pattern that usually belongs in a server-side route or a database function with a deliberate, checked elevation rather than in the client's own policy.

Read the rule

RowShield is an independent product with no affiliation to, or endorsement from, Supabase.