DEV Community

Pon
Pon

Posted on

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

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());
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)