Here's a pattern I keep running into when I look at Supabase apps that shipped a notifications feature: RLS is on, the SELECT policy is correct, everyone tests it, everyone sees only their own notifications, and the feature gets marked done. Nobody checks the other three commands.
A typical setup looks like this:
create policy "read own notifications"
on notifications
for select
to authenticated
using (recipient_id = auth.uid());
That policy is fine. It's also usually the only one anyone thinks about, because reading your own notifications is the only thing you personally test.
Where this breaks
Somewhere in the build, a feature needs the client to create a notification directly, someone likes a post, follows a user, whatever. The fastest way to make that work from the frontend is an INSERT policy that just lets it through:
create policy "authenticated can insert notifications"
on notifications
for insert
to authenticated
with check (true);
with check (true) means any signed-in user can set any recipient_id, with any title and body they want. The SELECT policy still only shows you your own notifications, so you never notice you can also write everyone else's. You're not scanning your own inbox for messages you didn't send, why would you.
But anyone holding a valid session token can do exactly that:
curl -X POST "https://YOURPROJECT.supabase.co/rest/v1/notifications" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_USER_JWT" \
-H "Content-Type: application/json" \
-d '{"recipient_id":"<someone-elses-uuid>","title":"Your payment failed","body":"Update your card: evil.example.com"}'
If that succeeds, you've just put a message of your choosing into a stranger's notification feed, from inside your own app's UI, using a phishing hook the recipient has every reason to trust.
Why this slips past both AI and manual review
The AI wired up exactly what was asked: a client-side path that lets a user action produce a notification. Verifying who the real sender is was never part of the request. Manual testing has the same blind spot. You only ever test your own account, which happens to be the one case this bug doesn't affect.
Check your own project in two minutes
List the policies on your notifications table and look at the INSERT row specifically:
select policyname, cmd, qual, with_check
from pg_policies
where tablename = 'notifications';
If the insert policy's with_check is true, blank, or has no link back to an actor/sender column you control server-side, that's the bug sitting right there.
The fix
Two ways to close it, in order of how much I'd trust each:
Constrain the insert to match the actor:
create policy "insert notifications you really triggered"
on notifications
for insert
to authenticated
with check (actor_id = auth.uid());
This at least ties the row to who really did it, so it's traceable and the client can't spoof actor_id. It still trusts the client to pick the right recipient_id, which is weaker than it sounds if any other column drives real behavior later.
Better: don't let the client write notifications at all. Derive them server-side from the event that should trigger them, with a trigger the client can't touch:
create or replace function notify_on_like()
returns trigger as $$
begin
insert into notifications (recipient_id, actor_id, type, post_id)
values (
(select user_id from posts where id = new.post_id),
auth.uid(),
'like',
new.post_id
);
return new;
end;
$$ language plpgsql security definer;
create trigger on_like_notify
after insert on likes
for each row execute function notify_on_like();
With this, the client's insert policy on notifications can just be false, or not exist at all. The only thing that ever writes there is the trigger, and recipient_id comes from the real post owner, never from client input.
I build with AI constantly and this isn't a reason to stop. It's a reason to stop treating "the notifications I see look right" as proof the table is safe, since that test only ever checks the one direction that was never broken. I put together a small tool that checks for this pattern and a few related ones across a repo automatically. If you want me to run it against yours for free, drop a comment.
Update, 13 Aug 2026. Mads Hansen pointed out in the comments that the fix above moves the authority into the trigger function and then leaves it unguarded, and he's right. Two things were missing.
security definer runs the body as the function's owner, and on Supabase that owner usually owns the table too. The RLS bypass comes from that ownership, not from the keyword. With no set search_path, the caller's path is still in force inside the body, so an unqualified notifications or posts resolves to whatever a schema earlier on that path happens to hold. Pin it to empty and qualify every name.
Mads named the second one too, in his line about deriving actor and recipient from the trusted source row. What it costs to skip: auth.uid() reads the request JWT, so the moment a like gets inserted from the server with the service role, or straight from SQL, there is no JWT and actor_id lands as NULL.
create or replace function public.notify_on_like()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.notifications (recipient_id, actor_id, type, post_id)
values (
(select p.user_id from public.posts p where p.id = new.post_id),
new.user_id,
'like',
new.post_id
);
return new;
end;
$$;
That version only holds if likes itself constrains who the liker is, so its insert policy is now load-bearing:
create policy "like as yourself"
on public.likes
for insert
to authenticated
with check (user_id = auth.uid());
Mads also suggested a unique key on the event identity so a retry cannot double-post the same notification. Worth having, with the caveat that if you allow unlike and re-like you need the old row gone first, or the second like goes quiet.
create unique index notifications_event_once
on public.notifications (type, actor_id, recipient_id, post_id);
Top comments (7)
Good writeup. The same blind spot has a third member that is easier to miss than INSERT: UPDATE policies with a
usingclause and nowith check.The two clauses do different jobs.
usingdecides which existing rows you may touch.with checkdecides what those rows are allowed to become. Write only the first and a user can legitimately update their own row and move it out of their own boundary in the same statement:That passes the obvious test, marking your own notification as read. It also lets someone take a row they own, rewrite title and body, and set recipient_id to a stranger. The row was theirs when the check ran, and Postgres does not re-check the result unless you tell it to.
It is worse than the INSERT case in one way: the SELECT policy then hides the evidence, because the row is no longer yours to read.
Fix is to state both, and they are often not the same predicate:
Extending your detection query to catch it:
Anything returned is a write path not constrained on the way in.
One more from the same family, on the read side: a SELECT policy that filters through a joined membership table where the join is not isolated, so a user belonging to two organisations satisfies it for both. Single-account tests stay green there too. I wrote that one up with a runnable red/green reproduction: dev.to/cekuu35/your-supabase-rls-p...
Your line about the tested direction being the one that was never broken is the real lesson. Each of these hides in a command or an identity nobody exercised.
I went to add the UPDATE case to the check in the post and got stopped by CREATE POLICY itself: for ALL and UPDATE, if no WITH CHECK expression is defined, the USING expression is used to determine both which rows are visible and which new rows will be allowed to be added. So on that policy as written, the recipient_id swap should come back as new row violates row-level security policy rather than land.
That changes what the detection query should pull. pg_policies runs with_check through pg_get_expr, which is strict, so a USING-only UPDATE policy reports NULL there while it's still being checked on the way in. The with_check is null half of the filter flags that safe shape, and the with_check = 'true' half is the one carrying the query. The cmd list also wants 'ALL' in it: a for all ... with check (true) sitting beside a correct policy never surfaces as UPDATE or INSERT, and permissive checks OR together, so one true anywhere in the set is the whole check. I only got to that one because you widened the query past a single table.
INSERT is the command with no USING to fall back on, which is why that's the one the post is about. Did you hit a table where the swap went through?
You're right, and that's cleaner than how I put it - thanks for the correction. A USING-only UPDATE with an owner-scoped USING (auth.uid() = recipient_id) is safe precisely because USING doubles as the WITH CHECK, so the recipient_id swap trips "new row violates row-level security policy" on the way in. I was collapsing two shapes: the dangerous one I actually meant is a FOR ALL / UPDATE whose USING is permissive (using(true), or otherwise not owner-scoped) with no WITH CHECK - there the "USING doubles as the check" rule cuts the wrong way and the write lands. Owner-scoped USING = safe; permissive USING = open.
On your question: mechanically INSERT is the one that lands (no USING to fall back on), so it's the headline. The version I keep hitting in the wild isn't a bare INSERT policy though - it's
for all ... using (true)on a sensitive table, which opens SELECT/INSERT/UPDATE/DELETE in one line and ORs past the correct owner-scoped policy next to it. That's the practical "swap goes through": any client writes an arbitrary row, recipient_id included.So the detector wants roughly cmd IN ('INSERT','ALL') AND (with_check = 'true' OR with_check IS NULL) - the NULL branch being your "inherits USING" case, which you then re-check against the USING expr to tell owner-scoped from permissive. And widened past the single table, since that stray for-all is the quiet re-opener. You nailed the pg_get_expr strictness / NULL-vs-'true' detail - that's the part most audit queries miss.
Good thread. I keep a small reproduction of this shape + the detection query as a fixture if it's ever useful: github.com/cekuu35/supabase-rls-le...
You've stated it more cleanly than I had. On a USING-only owner-scoped UPDATE the USING doubles as the WITH CHECK, so the recipient_id swap gets re-evaluated against the new row and bounces with "new row violates row-level security policy" — exactly as you say. So with_check IS NULL on an UPDATE is the safe shape, not the leak, and I was wrong to lump it in.
To your direct question: yes, but never on that USING-only UPDATE shape. The two places I've actually watched a write land on someone else's row:
true.truefrom USING propagates as the write check, so an UPDATE that rewrites the owner column does go through. pg_policies shows qual = true, with_check = null there, which is the one spot a null with_check is actually dangerous.So the corrected write-leak query is two shapes OR'd, and it has to read the whole policy set per table (permissive policies OR, so one
trueanywhere is the whole check):The with_check = 'true' half catches the explicit permissive checks (including a FOR ALL WITH CHECK (true) hiding next to a correct policy — which is why ALL has to be in the cmd list); the second half catches USING(true) being reused as the check. Dropping the bare with_check IS NULL term is the fix your UPDATE case forced. Thanks — it's sharper for it.
You're right, and this sharpens my original point. A USING-only UPDATE/ALL reuses USING as the check, so if that USING is ownership-scoped (recipient_id = auth.uid()) the swap comes back as
new row violates row-level security policy. So with_check IS NULL is the safe shape — the blind spot isn't the missing WITH CHECK, it's a permissive USING sitting underneath it.So the write-side filter is really: with_check = 'true' OR (cmd = 'ALL' AND qual = 'true' AND with_check IS NULL), with cmd spanning INSERT/UPDATE/ALL and roles including anon/authenticated, since permissive policies OR together.
And to your question: yes. Everywhere a write into someone else's row actually landed, it was a literal WITH CHECK (true) on INSERT, or a USING (true) on the UPDATE/ALL path — never a USING-only policy with a real ownership predicate. INSERT is just the loudest because there's no USING to fall back on.
Deriving the notification from the source event is the stronger design. One authority boundary then moves into the trigger function itself:
SECURITY DEFINERexecutes as its owner and may bypass RLS.I’d own that function with a non-login role, set an empty/safe
search_pathand schema-qualify every object, revoke directEXECUTEfrompublic/authenticated, and ensure the function derives actor and recipient entirely from the trusted source row. Direct client DML onnotificationsshould remain denied.It is also worth making retries harmless with a unique event identity such as
(type, source_id, actor_id, recipient_id). CI can then test as anon, authenticated user A/B, and service role: direct insert, direct function call, spoofed recipient/actor, duplicate event, deleted source row, and a migration that changes function owner orsearch_path. That catches a secure RLS policy being quietly bypassed by the mechanism intended to fix it.You're right that the trigger is where the authority moves, and mine is missing half of what should come with it. No set search_path, and nothing inside it is schema-qualified. Owned by a role that can write past RLS, that means whoever can create objects in a schema ahead of public on the resolved path gets to decide what notifications and posts point at when the insert runs. set search_path = '' plus qualifying every name is the part I should have put in the post.
The revoke is the one I'm unsure about. A function that returns trigger can't be invoked as a normal call, Postgres refuses it before the body runs, and PostgREST doesn't expose it as an RPC either. So is that revoke closing a path I'm not seeing, or is it there for the migration that later changes the return type?