Cenk Kurtoğlu©26

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 behind

Note 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):

  1. As user A: update public.projects set tenant_id = '<tenant B>', name = 'moved' where id is a row user A already owns.
  2. 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

Reference

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.

Template bundle

Review all 20 frontend starters in one bundle

20 editable frontend starters with live demos are $79 — one payment, no subscription.

View the Bundle — $79↗