DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

New Row Violates Row-Level Security Policy: The Real Fix

I build tooling that scans Supabase projects for Row Level Security mistakes, and I keep running into the same reassuring, wrong mental model: "RLS is on, my SELECT policies are scoped to auth.uid(), so my data is locked." The read side often is fine. The trouble is that reads and writes go through different doors, and most audits only check the one they can see.

Reads and writes are checked by different expressions

A Postgres RLS policy has two halves:

  • USING — the visibility filter. It decides which existing rows a query can see (SELECT, and which rows an UPDATE/DELETE is allowed to touch).
  • WITH CHECK — the write filter. It decides which new row values are allowed to land (INSERT, and the post-image of an UPDATE).

The catch is in how they default:

  • INSERT policies have WITH CHECK only. There is no USING to fall back on — an insert has no existing row to filter.
  • UPDATE policies check USING against the old row and WITH CHECK against the new row. Omit WITH CHECK, and Postgres reuses the USING expression as the check.
  • FOR ALL applies USING to reads and to the old-row side of UPDATE/DELETE, and WITH CHECK to the new row on INSERT/UPDATE — and again, omit WITH CHECK and USING is reused.

Your read-audit greps for SELECT policies and confirms they say auth.uid() = user_id. It never reads the write filter. That's the blind spot.

Leak #1: WITH CHECK (true) on INSERT

Here's a table where reads are perfectly locked down:

create policy "read own messages"
on public.messages for select
using ( auth.uid() = user_id );
Enter fullscreen mode Exit fullscreen mode

And here's the door someone left open, usually to "just get inserts working":

create policy "allow inserts"
on public.messages for insert
with check ( true );
Enter fullscreen mode Exit fullscreen mode

with check (true) accepts every new row. A policy with no TO clause applies to public — every role, anon included. So anyone holding the public anon key can insert a row with user_id set to someone else and write straight into another user's inbox, feed, or audit log. Reads are locked; writes are wide open.

The fix is to make the write filter say what the read filter says:

create policy "insert own messages"
on public.messages for insert
with check ( auth.uid() = user_id );
Enter fullscreen mode Exit fullscreen mode

The subtlety: FOR ALL USING (true)

This one trips up even careful people, so let's be exact about what is and isn't a bug.

A USING-only UPDATE is safe:

create policy "update own todos"
on public.todos for update
using ( auth.uid() = user_id );      -- no with_check
Enter fullscreen mode Exit fullscreen mode

Because WITH CHECK is omitted, Postgres reuses auth.uid() = user_id as the write check. Try to rewrite user_id to another account and the new row fails that reused check:

new row violates row-level security policy for table "todos"
Enter fullscreen mode Exit fullscreen mode

So with_check IS NULL on an UPDATE is the safe shape, not the leak. Don't let an audit flag it.

Now the actual leak — same reuse rule, opposite outcome:

create policy "manage own todos"
on public.todos for all
using ( true );                      -- no with_check
Enter fullscreen mode Exit fullscreen mode

USING (true) already over-shares — it exposes every read and every delete across the table. But with no WITH CHECK, that true is also reused as the write check, so an INSERT, or an UPDATE that rewrites user_id, lands cleanly too. In pg_policies this shows as qual = true, with_check = null: the one place a null with_check is genuinely dangerous, because the qual it inherits is true.

Permissive policies OR together

Multiple permissive policies for the same command combine with OR — one policy that passes anywhere is the whole verdict. So this, sitting quietly beside your correct policy:

create policy "service writes"
on public.todos for all
with check ( true );
Enter fullscreen mode Exit fullscreen mode

silently re-opens every write, however good its neighbor is. And it surfaces as cmd = 'ALL', not INSERT/UPDATE — exactly why an audit that filters only for INSERT and UPDATE walks right past it.

A write-leak detection query

Run this against your project. It flags the anon/public write policies that let anyone write, and deliberately spares the safe USING-only update:

select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
  and cmd in ('INSERT','UPDATE','ALL')
  and ('anon' = any(roles) or 'public' = any(roles))
  and ( with_check = 'true'
        or (cmd = 'ALL' and qual = 'true' and with_check is null) );
Enter fullscreen mode Exit fullscreen mode

Reading it clause by clause:

  • cmd in ('INSERT','UPDATE','ALL') — writes only, and 'ALL' is in there because a stray FOR ALL WITH CHECK (true) surfaces as cmd = 'ALL', never as INSERT/UPDATE. Leave it out and you miss the most common way a good schema gets quietly re-opened.
  • 'anon' = any(roles) or 'public' = any(roles) — this scopes the scan to the anon-key path, the highest-severity, false-positive-free class: anyone with your public key. qual and with_check are text columns, so these are string comparisons against the literal 'true'.
  • with_check = 'true' — catches explicit permissive checks, including FOR ALL WITH CHECK (true).
  • cmd = 'ALL' and qual = 'true' and with_check is null — catches USING (true) being reused as the check.

A bare with_check IS NULL term is intentionally not here — it would flag the safe USING-only update and bury you in false positives. One honest caveat: this query targets the anon/public class. Over-permissive writes scoped TO authenticated (any logged-in user writing another tenant's rows) are a real but separate, narrower class — widen the role filter if you want to sweep for those too.

It's the policy, not the key

When people find one of these, the first instinct is often to rotate the anon key. Don't bother. The anon key is public by design — it ships in your client bundle, and it's meant to. It isn't a secret, and rotating it fixes nothing. The vulnerability is the policy that lets a caller in the anon/public scope write where it shouldn't. Fix the WITH CHECK, not the key.


If you'd rather feel this than take my word for it, I put together a small reproduction: github.com/cekuu35/supabase-rls-leak-demo stands up the leaky policies, shows a cross-tenant write succeeding, then applies the fix — so you can confirm your own detection query catches it before someone else does.

For teams who'd rather not hand-roll the checklist, I also keep a paid Supabase RLS Audit Kit — though honestly, the query above and an afternoon will get most projects there.

Top comments (0)