PL-012criticalcoming · not tested yet

Strangers can change or delete other people's rows

A write policy checks that someone is signed in, not that the row is theirs. Anyone who signs up can edit or wipe it.

Severity
Critical
Who can exploit it
Anyone who signs up
Found by
Not tested yet: coming to PatchLabs Lab
Status
Coming

PatchLabs does not test for this yet (coming). This page explains the problem, how to check your own project and how to fix it. The Lab’s live checks today are PL-001 to PL-008; this one is not part of any report until it ships. Shipping dates go in the changelog.

What it is

Row level security has separate rules for reading, adding, changing and deleting rows. PL-012 means the rule for changing (UPDATE) or deleting (DELETE) checks only that the caller is signed in, for example to authenticated using (true) or using (auth.uid() is not null), and never compares the row's owner with the caller. If your app has open sign-up, a stranger creates an account and edits or deletes other people's posts, bookings or orders.

Postgres also applies your SELECT policies when an update or delete filters on a column (.eq('id', …) in supabase-js), so in practice the stranger also needs to be able to see the row. Many apps make rows readable to every signed-in user on purpose (a feed, a marketplace, a directory), and that combination is what makes this exploitable.

Two neighbours: in PL-005 the policy is always-true for every role, including logged-out visitors; in PL-004 users change their own rows in ways they shouldn't.

Why AI builders produce it

When an app shows "new row violates row-level security policy", the shortest change that makes the error go away is a policy that only asks "is the user signed in?". It reads as careful, because it mentions authentication, and it works in a demo where the only user is the builder. The difference only shows up with a second account, and most people never test with one.

Example of vulnerable code

Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.

supabase/migrations/…_posts.sql (vulnerable)
create table public.posts (
  id bigint generated always as identity primary key,
  user_id uuid not null references auth.users (id) on delete cascade,
  title text not null,
  body text
);
alter table public.posts enable row level security;

create policy "Posts are visible to signed-in users"
  on public.posts for select to authenticated using (true);

-- Checks sign-in, not ownership
create policy "Signed-in users can update posts"
  on public.posts for update to authenticated using (true);
create policy "Signed-in users can delete posts"
  on public.posts for delete
  using (auth.uid() is not null);

Check it yourself

Run these in the SQL editor of your own project. The second block acts as a brand-new user who owns nothing, inside a transaction that is rolled back.

check-pl-012.sql
-- 1. UPDATE and DELETE policies that never compare the row with the caller
select tablename, policyname, cmd, roles, qual
from pg_policies
where schemaname = 'public'
  and cmd in ('UPDATE', 'DELETE', 'ALL')
  and (coalesce(qual, '') not ilike '%auth.uid()%'
       or qual ilike '%auth.uid() is not null%');

-- 2. Try an update as a signed-in stranger (nothing is changed)
begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"00000000-0000-0000-0000-000000000000","role":"authenticated"}', true);
update public.posts set title = title
where user_id <> '00000000-0000-0000-0000-000000000000'
returning id;  -- any id returned is a row a stranger can change
rollback;

The fix

Scope both writes to the row's owner. USING picks the rows a user may touch; WITH CHECK stops them handing a row to someone else.

supabase/migrations/…_fix_post_writes.sql
drop policy "Signed-in users can update posts" on public.posts;
drop policy "Signed-in users can delete posts" on public.posts;
create policy "Authors update own posts"
  on public.posts for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
create policy "Authors delete own posts"
  on public.posts for delete to authenticated
  using ((select auth.uid()) = user_id);

If moderators must delete any post, add a separate delete policy for them using a role helper such as public.has_role('moderator') (see PL-020), rather than widening the author policy.

PatchLabs doesn’t write this fix yet. Review the SQL with your engineer and add it as a new migration.

How PatchLabs will test it

When this check ships, the Lab will run it the way it runs PL-001 to PL-008 today: rebuild your database from supabase/migrations in a disposable Postgres, seed fake rows for two users, and try the exploit as an anonymous visitor, a signed-in stranger and the owner. Until then, the self-check above is the test.

When the pattern is intended

Shared editing is real: a team document, a project board, a wiki. Even then the rule is "members of this team", not "anyone signed in", so scope the policy to the membership table your app already has. A table where literally every account may edit every row is rare: a public wiki where accounts are approved by hand is the usual example.

Why it’s rated critical

Critical, because the attacker needs nothing but a free account, and the damage lands on other people: rewritten content, cancelled bookings, deleted records. Deletes can't be undone without a backup.

Supabase linter

0024 permissive_rls_policy
Flags the policy only when it is literally true. using (auth.uid() is not null) does the same thing and passes the lint.

Free check

Check what PatchLabs does test today

This check is coming. Today’s free check rebuilds your database from your migrations, tests it as a visitor, a stranger and the owner (PL-001 to PL-008), and reads your repo for keys and risky code.

Free for your first appReads your public repo onlyOr run the free static RLS check

Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.