Two permissive UPDATE policies, both individually verified correct, but the combined UPDATE still throws 42501 #49709
Replies: 9 comments 2 replies
|
Quick read thru and not looking at details but for update you must also meet select policy on the current row and the new row. |
|
The reason you are hitting Root CauseWhen PostgreSQL evaluates the subquery: exists (select 1 from profiles where profiles.id = auth.uid() and profiles.is_admin = true)inside the
The Canonical Fix: Wrap the Check in a
|
|
I built your setup on a local PostgreSQL 16.15 with an The one requirement is a SELECT policy that lets the admin see the row. With no SELECT policy, or an owner-only one, the same UPDATE returns Both explanations so far predict the wrong symptom. I tested them:
RLS on The error text itself narrows it, and the detail is easy to miss. I reproduced two different forms: Yours has no policy name, which is the second form. That means the row passed So the thing to check is what is actually stored, rather than what was intended: select policyname, permissive, cmd, qual as using_expr, with_check as with_check_expr
from pg_policies
where tablename = 'points' and cmd in ('UPDATE', 'ALL')
order by policyname;The failure mode looks like this, and it is the shape that reproduces your exact error: Both Two smaller things worth knowing. Leaving Caveat on method: this was local PostgreSQL with an |
|
In PostgreSQL Row Level Security, when multiple permissive
Why the error occurs:If the second policy's The Fix:Use a -- 1. Create secure helper:
create or replace function public.is_admin()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles
where id = auth.uid() and is_admin = true
);
$$;
-- 2. Consolidate into a single clean policy:
drop policy if exists "points_soft_delete" on points;
drop policy if exists "admins_update_any_point" on points;
create policy "points_update_policy" on points
for update to authenticated
using (
auth.uid() = staff_id or public.is_admin()
)
with check (
auth.uid() = staff_id or public.is_admin()
);Using a |
|
@paramjeetn that middle claim is testable, and it does not hold. Two permissive policies, deliberately split so that neither satisfies both halves: -- passes USING, fails WITH CHECK
create policy a on t for update to authenticated using (true) with check (false);
-- fails USING, passes WITH CHECK
create policy b on t for update to authenticated using (false) with check (true);The update succeeds. Neither policy satisfies both expressions on its own, so the two sides are OR'd independently across permissive policies rather than needing to come from the same one. The control confirms which half raises the error. Change B's Restore B's So 42501 here means every permissive Which is why I would still go and read what is actually stored rather than what was intended: select policyname, permissive, cmd, qual as using_expr, with_check as with_check_expr
from pg_policies where tablename = 'points' and cmd in ('UPDATE','ALL') order by policyname;If both rows come back with PostgreSQL 16.15, same throwaway container as before. |
|
Your two permissive UPDATE policies DO OR together for both USING and WITH CHECK ? that part of the model is correct, and I think it is a red herring. The real trap is that an The part everyone misses: SELECT policies apply to UPDATE tooWhen PostgreSQL runs an
So "the check evaluates true standalone" is expected: you are testing the two UPDATE predicates in isolation, but PostgreSQL is also running the SELECT predicates against BOTH the old and the new row and ANDing them with your UPDATE checks. What to check (in order, fast)-- 1. Does your client write use RETURNING / .select()?
-- (this is THE most common cause ? the UPDATE works, the read-back is rejected)
-- 2. List the SELECT policies that also apply on UPDATE:
select polname, cmd, polqual, polwithcheck
from pg_policy
where polrelid = 'points'::regclass;
The giveaway that it is this and not your UPDATE policies: your own test in #2 ? dropping If you can paste |
|
jayblast's restrictive-policy check is the right next filter. To rule it in/out in one shot: select polname, polpermissive, polcmd,
pg_get_expr(polqual, polrelid) as qual,
pg_get_expr(polwithcheck, polrelid) as with_check
from pg_policy
where polrelid = 'public.points'::regclass;If no RESTRICTIVE policy appears, the two permissive UPDATE policies do OR together (USING and WITH CHECK both) — your test #2 proves it. The remaining cause for 42501 with both policies present is typically the SELECT policy gate triggered by RETURNING (PostgREST/supabase-js Quick verification: run the bare UPDATE without RETURNING (e.g. Post the |
|
This is permissive policy OR-behavior — when multiple permissive policies exist for the same command, Postgres ORs their What happens: Policy A allows update if Root cause: One of your policies likely has a Fix options:
Debug query: SELECT policyname, cmd, permissive, roles, qual, with_check
FROM pg_policies WHERE tablename = 'your_table' AND cmd IN ('UPDATE', 'ALL');
-- Check: are both PERMISSIVE? Do their qual expressions OR to false?Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo |
|
Hey all — really appreciate the depth everyone's put into this, especially @VivensB's actual repro work and @cekuu35's SELECT-policy theory. Wanted to close the loop with what I found after digging further, because it rules out a lot of ground and I think it's worth having on record here. Applied the SECURITY DEFINER fix. Wrapped the check in So the admin check was never the problem. I went further and tested every remaining piece directly, in the same session, right before the failing statement. Both UPDATE policies confirmed permissive:
Also tested @cekuu35's RETURNING/SELECT-policy theory directly — ran the update with no At this point I've disproven every specific mechanism proposed here, empirically, one at a time, in the exact failing session. Per Postgres's documented behavior, an OR of two permissive conditions where one is true should pass — full stop. I'm out of theories that fit the documented model. I'm on the free tier so a real support ticket isn't in the cards — does anyone know of a Supabase-specific quirk in how permissive UPDATE policies actually combine, something that wouldn't show up in |



Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
I've got a table (points) with two permissive UPDATE policies:
sql
create policy "points_soft_delete" on points
for update to authenticated
using (auth.uid() = staff_id)
with check (auth.uid() = staff_id);
create policy "admins_update_any_point" on points
for update to authenticated
using (
exists (select 1 from profiles where profiles.id = auth.uid() and profiles.is_admin = true)
)
with check (
exists (select 1 from profiles where profiles.id = auth.uid() and profiles.is_admin = true)
);
Goal: an admin (is_admin = true) should be able to update any row, not just their own (staff_id). Non-admins should still be limited to their own rows via the first policy.
Testing as an admin trying to update a row where staff_id belongs to someone else — I keep hitting:
ERROR: 42501: new row violates row-level security policy for table "points"
Here's what I've independently confirmed, using a simulated session (set local role authenticated; set local request.jwt.claims = '{"sub": "", "role": "authenticated"}'):
sql
select
id, staff_id,
(staff_id = auth.uid()) as owns_point,
exists (select 1 from profiles where profiles.id = auth.uid() and profiles.is_admin = true) as admin_check
from points where id = '';
-- owns_point: false, admin_check: true
With admins_update_any_point temporarily dropped (inside a transaction, rolled back after), the same UPDATE against points_soft_delete alone returns 0 rows, no error — confirming that policy behaves exactly as documented on its own (silently filters non-owned rows rather than throwing).
With both policies present, both given explicit matching WITH CHECK clauses (shown above — no implicit-fallback ambiguity), the identical UPDATE still throws 42501, across multiple fresh sessions/connections.
Also ruled out: RLS is enabled (relrowsecurity = true), no other UPDATE policies on the table, no triggers, full column-level UPDATE grants to authenticated on every column being written, and profiles itself has a permissive select true policy so the subquery isn't blocked.
Given #1 proves the check is logically true and #2 proves the other policy works correctly alone, I'd expect the two permissive policies to OR together and let this through. Anyone run into something like this, or see something I'm missing? Happy to share more (schema, full policy list, etc.) if useful.
localhosthar.txt
All reactions