The answer. Each verb reads a policy differently. SELECT and DELETE use using to decide which existing rows are visible. INSERT uses with check to vet the row being written. UPDATE uses both: using against the current row (may I touch it) and with check against the proposed row (may it end up like this); leave with check out and Postgres reuses the using expression, which is fine, but write with check (true) and a tenant member can move a ticket into another tenant. The second thing is how failure looks: it does not. An UPDATE or DELETE whose rows are filtered out by RLS affects zero rows and returns no error, so client code that checks only error believes it succeeded. Ask for the rows back and assert on them; our RLS test suite treats "zero rows returned" as the negative case, never a thrown error.
The pattern.
create policy "member updates ticket" on public.tickets for update to authenticated
using (rls_helpers.has_tenant_access(tickets.tenant)) -- which rows
with check (rls_helpers.has_tenant_access(tickets.tenant)); -- which values (no tenant hopping)
create policy "author deletes ticket" on public.tickets for delete to authenticated
using (rls_helpers.is_same_profile(tickets.created_by));
const { data, error } = await supabase.from("tickets").update({ status: "done" }).eq("id", id).select("id");
if (error) throw error;
if (!data?.length) throw new Error("not permitted or not found"); // the only signal you get
| Verb | using | with check |
|---|---|---|
| select | rows visible | — |
| insert | — | new row |
| update | rows touchable | new row |
| delete | rows deletable | — |
Watch out.
- RLS is row-level. A user allowed to update a row may rewrite every column of it, including
created_by; column privileges or a reset trigger close that. - PostgREST refuses UPDATE and DELETE without a filter (
pg-safeupdate), so.update(x)with no.eq()errors instead of touching the table. Keep that on. - INSERT and SELECT are separate: writing a row does not grant reading it back.
Related: column-privileges-hide-columns-rls-hides-rows · set-created-by-in-a-before-insert-trigger-never-from-the-client · rls-test-set-local-role-and-jwt-claims