Supabase database
Supabase Realtime security: keep private rows out of subscriptions
Supabase Realtime sends a table’s inserts and updates to a subscriber only if that subscriber could read the row under the table’s row level security policies, so a Realtime stream is exactly as private as the table’s read policy. If a published table’s policy is loose, or RLS is off, private rows stream to anyone who subscribes, the moment they are written.
TL;DR
- A table streams changes when it is in the
supabase_realtimepublication (the dashboard’s Realtime switch adds it). - For each change, Realtime checks the subscriber’s own token against the table’s
SELECTpolicies. - So fix the read policy, not the subscription. Client-side filters are not security.
- Delete events can’t be checked against a deleted row; with RLS on they carry only the primary key.
How Postgres Changes decides who gets what
Realtime’s “Postgres Changes” feature reads your database’s change stream and forwards changes to browsers subscribed to a table. Only tables in the supabase_realtime publication take part. Turning on Realtime for a table in the dashboard, or running alter publication supabase_realtime add table … in a migration, adds it.
For a table with row level security on, Realtime checks each change against each subscriber before sending it, using the subscriber’s JWT and the table’s SELECT policies: if that user couldn’t read the row with a normal query, they don’t receive it. For a table with RLS off, there is nothing to check, and every subscriber gets every change.
That is a good design, and it means the question “is my Realtime secure?” is really “is my read policy right?”.
How private rows end up streaming
Chat, notifications and live dashboards are where this shows up. The table was created with a loose read policy during development, when every user was the same test account, and Realtime was switched on later to make messages appear without a refresh:
create policy "Signed-in users can read messages"
on public.messages for select to authenticated using (true);
alter publication supabase_realtime add table public.messages;Any signed-in user can now run this in your app’s browser console and watch other people’s private messages arrive:
supabase
.channel('anything')
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload) => console.log(payload.new))
.subscribe();They could also just query the table, of course; the policy is the real problem (PL-002). Realtime makes it worse: data arrives as it is created, with nothing to notice.
Check which tables stream, and to whom
-- Tables published to Realtime, and whether RLS is on
select pt.schemaname, pt.tablename, c.relrowsecurity as rls_on
from pg_publication_tables pt
join pg_namespace n on n.nspname = pt.schemaname
join pg_class c on c.relnamespace = n.oid and c.relname = pt.tablename
where pt.pubname = 'supabase_realtime';
-- The read policies that decide who receives their changes
select tablename, policyname, roles, qual
from pg_policies
where schemaname = 'public' and cmd in ('SELECT', 'ALL')
and tablename in (select tablename from pg_publication_tables
where pubname = 'supabase_realtime');Treat any published table with rls_on = false, or with a read policy that doesn’t mention the user, as streaming to everyone that policy allows. Confirm with the stranger test from the RLS guide: whatever the stranger can select, the stranger can receive.
Fix the read policy
drop policy "Signed-in users can read messages" on public.messages;
create policy "Participants read their messages"
on public.messages for select to authenticated
using ((select auth.uid()) in (sender_id, recipient_id));Realtime picks up the new policy automatically: subscribers now receive only messages they are part of. If nothing in your app listens to a published table, remove it from the publication rather than leaving it streaming:
alter publication supabase_realtime drop table public.audit_log;Filters are for efficiency, not security
A subscription can carry a filter, such as filter: 'recipient_id=eq.' + user.id. Use it: it cuts the traffic each browser receives. But the subscriber writes the filter, so an attacker simply leaves it off. Only the policy is enforced by the server.
Delete events
Postgres can’t check a policy against a row that no longer exists, so delete events can’t be filtered per user and go to every subscriber of the table. When RLS is on, the old record in a delete event carries only the primary key, so what leaks is the fact that a row with that id was deleted. If even that matters, don’t subscribe to deletes on that table.
Broadcast and Presence
Realtime also has Broadcast (messages between clients) and Presence (who is online). These don’t go through your table policies. Channels are public by default; private channels are authorized with policies on the realtime.messages table. If you send anything personal over Broadcast, use private channels and write those policies.
Checklist
- Every table in
supabase_realtimehas RLS on. - Each of their read policies limits rows to the people meant to see them.
- Tables nothing subscribes to are out of the publication.
- Anything personal over Broadcast uses private channels.
Sources
Related findings
The checks behind this guide, each with example code, a self-check and the fix.
Realtime sends private rows to subscribers
PL-002Any signed-in user can read every row
PL-001Anonymous visitor can read personal data
Free check
Check your app for this, free.
Paste your public repo. PatchLabs rebuilds your database from its migrations, tests it as a visitor, a stranger and the owner, reads the code for keys and risky patterns, and hands you the fix.
Written by Manraj Singh Dhillon. Last checked 10 Nov 2026. Code samples are synthetic and run against your own project; nothing here names a real app. Spotted a mistake? Tell us.