← All lessons

Your notifications table has RLS on. Anyone signed in can still write into someone else's inbox.

2026-07-20 · Vollos Lens

Here's a pattern that shows up constantly when we review Supabase apps with 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 trigger is. 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, straight from the browser, no exploit required, just a normal authenticated request with a different id in the body.

If that succeeds against your project, you've just put a message of your choosing into a stranger's notification feed, from inside your own app's UI, using a channel 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 we'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.

Three habits that catch this

  • Read with_check, not just using. People lock down reads first because that's the one they personally test. Writes get left open.
  • Ask why the client is writing at all. If a table only ever needs to be written by the system in response to an event, the client's write policy should be false, not a loosened true.
  • Log in as a second test user and try to insert a row pointed at the first user. If it succeeds, you found it before someone else did.

None of this is a reason to stop using AI. It builds fast and that's real. The catch is that the speed you get on building is speed you have to spend back on checking, because it will hand you something that runs and let you believe it's finished.

Want a second pair of eyes on the inside parts — the policies, the routes, the views? Read a full sample review or send us your repo.