Supabase security · 6 minute read
Supabase RLS write leak: your UPDATE policy has no WITH CHECK
Your SELECT policy is scoped to the owner, so reads are safe. The UPDATE policy on the same table has USING (true) and no WITH CHECK. That combination passes every visual review and lets any authenticated user move a row to a different tenant with one UPDATE. The row does not disappear from your dashboards, nothing errors - the leak is silent.
USING and WITH CHECK check different moments
USING decides which existing rows the statement may target. WITH CHECK decides whether the new row state is allowed. An UPDATE policy with only USINGvalidates where the row starts, not where it ends. A user of tenant A who can see a row can pass that row's id into their UPDATE and set tenant_idto tenant B's - the USING clause only asks "could they see the old row", which they could.
The same asymmetry applies to INSERT: an INSERT policy has no USING to fall back on, so it needs WITH CHECK to constrain anything at all.
The query that finds it
Run this read-only in the SQL Editor. Any row with UPDATE in cmd whose with_check is NULL or a bare true is a candidate:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
and cmd in ('UPDATE', 'ALL')
and (with_check is null or with_check::text = 'true')
order by tablename;Then filter by whether the table carries a tenant column at all - tenant_id, org_id, account_id, user_id. A hole on a table with no ownership column is usually intentional; a hole on a tenant table is a leak.
The fix, with both halves
drop policy if exists "users update their rows" on public.projects;
create policy "owners update their projects" on public.projects
for update to authenticated
using ((select auth.uid()) = user_id) -- which rows they may target
with check ((select auth.uid()) = user_id); -- which rows they may leave behindNote the (select auth.uid()) wrapper around auth.uid() - it is evaluated once per statement instead of once per row, and it is the form Supabase recommends in policy expressions.
The two-user test that proves it
The bug only exists in the write path, so the test has to be a write. With two sessions (user A in tenant A, user B in tenant B):
- As user A:
update public.projects set tenant_id = '<tenant B>', name = 'moved'where id is a row user A already owns. - As user B:
select * from public.projects where tenant_id = '<tenant B>'.
The fix works when step 2 does not return the moved row and user A's UPDATE either fails or silently matches zero rows. A SELECT-only test will never catch this class of bug - which is exactly why most audit checklists miss it.
Where this shows up in practice
- Tables created early, secured late. The first version allows any write while auth is being set up, and the USING clause gets tightened later while WITH CHECK is forgotten.
- AI-generated policies. Code generators reliably emit the USING half because the SELECT example is the common one, and stop there.
- FOR ALL policies. A single
for allpolicy with only USING silently covers INSERT, UPDATE and DELETE with one missing check.
Reference
- The per-identity access matrix (what each role can really reach per table, across SELECT, INSERT, UPDATE and DELETE) and the write-path tests are documented in my public repo: github.com/cekuu35/supabase-rls-leak-demo.
If you want the whole matrix checked by hand
I run a fixed-scope read-only review: every policy against every role, the write-path negative tests above, and remediation SQL per finding. See the review scope and pricing.