Supabase Realtime not working with RLS? 3 causes and fixes
Supabase Realtime checks every Postgres Changes event against each subscriber's RLS policies and silently drops the ones that user can't SELECT. So when Realtime goes quiet with RLS on, check three things in this order: the table is in the supabase_realtime publication, the subscribing role has a GRANT SELECT plus a SELECT policy that matches the row, and the client has the user's session before it subscribes (otherwise your policies run as anon). On a private channel, the user also needs a SELECT policy on realtime.messages, or the join is refused.
That behavior is a feature, not a bug — it's what stops a live subscription from becoming a firehose of other people's rows. But it means an RLS mistake shows up as silence, which is the hardest failure mode to debug. Here's how to work out which of the three you have.
Why is Supabase Realtime not working with RLS?
Because the events are being correctly withheld from a subscriber who isn't allowed to read the underlying rows. Supabase's documentation states that Postgres Changes "authorizes every event against each subscriber," so your policies act as a per-user filter on the stream, not just on queries.
Work through the causes in this order — they're ordered by how often they're the actual answer.
1. The table isn't in the publication. Realtime only broadcasts changes for tables added to the supabase_realtime publication. This is separate from RLS and catches people constantly, because nothing errors — you just get no events at all.
-- What's currently published?
select tablename
from pg_publication_tables
where pubname = 'supabase_realtime';
-- Add your table
alter publication supabase_realtime add table public.messages;
If your subscription never fires for anyone, including a user who can plainly read the row, start here.
2. The subscribing role has no SELECT path to the row. Realtime evaluates access as the role in the subscriber's JWT — anon or authenticated. That requires both halves of Postgres access control: a GRANT, and a policy that returns the row.
-- Grant (missing grants produce permission errors, not empty results)
grant select on public.messages to authenticated;
-- Policy: the subscriber must be able to SELECT the row to get its event
create policy "read own room messages" on public.messages
for select to authenticated
using (
exists (
select 1 from public.room_members m
where m.room_id = messages.room_id
and m.user_id = auth.uid()
)
);
The distinction matters when you're debugging: a missing GRANT is permission denied for table, while a policy that doesn't match is a silent empty result. Same symptom in Realtime — no events — but different fixes.
3. auth.uid() is NULL in the subscription's context. If the client connected before a session existed, Realtime is evaluating policies as anon. Policies are recalculated when a new JWT reaches Realtime via the access_token message, so re-subscribing after sign-in (or ensuring the client has the session before it subscribes) is the fix. If auth.uid() is NULL in your normal queries too, that's a broader wiring problem and Realtime is just the messenger.
The DELETE caveat you need to know about
This is the part that surprises people, and it's a security consideration rather than a troubleshooting step. From Supabase's Postgres Changes documentation:
RLS policies are not applied to DELETE statements, because there is no way for Postgres to verify that a user has access to a deleted record.
The row is already gone when the event fires, so there's nothing left to evaluate a policy against. Every subscriber to that table receives the delete event.
What limits the damage is how little is sent. With the default replica identity, Postgres only logs a deleted row's primary key, so that's all the old record can carry. If you set replica identity full to get complete old rows, Realtime still trims a DELETE's old record to the primary-key columns whenever RLS is enabled on the table: realtime.apply_rls, the function that builds every Postgres Changes payload, keeps only key columns in that case. So replica identity full on an RLS-protected table does not broadcast the deleted row's other columns. Supabase's Postgres Changes guide used to spell this out and dropped the sentence in August 2026; the behavior is still in Realtime's source.
The practical takeaway: don't put anything sensitive in a primary key on a table you replicate. A UUID is fine. A composite key of (user_email, document_title) is a leak on every delete, to every subscriber. If your app needs authorization-aware deletes, use soft deletes — an UPDATE setting deleted_at, which is filtered by RLS like any other update.
Private channels and Broadcast
Postgres Changes is only one of Realtime's features. Broadcast and Presence don't touch your tables at all, so table RLS has nothing to say about them — authorization there runs through policies on realtime.messages.
Mark the channel private on the client:
const channel = supabase.channel('room-1', { config: { private: true } })
Then write the policy that decides who may join that topic:
create policy "members can receive broadcast"
on realtime.messages
for select to authenticated
using (
exists (
select 1 from public.room_members m
where m.room_id = (realtime.topic())::uuid
and m.user_id = auth.uid()
)
);
Two things to internalize about this:
- A public channel is genuinely public. Anyone with your publishable key can join a channel by name and receive whatever is broadcast on it. If the payload is user data, the channel must be private with a policy behind it — the same reasoning that makes the anon key safe only when RLS is on.
- Authorization is checked at connection time and cached for the duration of the connection. Supabase caches client access policies so your database isn't queried per message. Revoking someone's membership does not kick them off a live channel: they keep receiving messages until their JWT expires or the client sends a new one. Keep JWT expiry short if membership changes need to take effect quickly.
Note that a private channel using Postgres Changes still needs a basic policy on realtime.messages allowing the connection, in addition to the table policies that filter the events themselves.
A quick self-check
-- 1. Is the table published?
select tablename from pg_publication_tables where pubname = 'supabase_realtime';
-- 2. Does RLS exist, and are there policies?
select relname, relrowsecurity, relforcerowsecurity
from pg_class where relname = 'messages';
select policyname, cmd, roles from pg_policies where tablename = 'messages';
-- 3. Can the subscribing role actually read a row?
set role authenticated;
select * from public.messages limit 1; -- empty here == silent in Realtime
reset role;
Step 3 is the one that ends most of these investigations. If a plain SELECT as authenticated returns nothing, Realtime withholding events is your policies working exactly as designed.
Being straight about tooling: this is a runtime and database-state problem. GuardLayer is a static scanner — it reads your source and SQL migrations, so it can flag a migration that creates a table with no RLS or a wide-open USING (true) policy that would let a public channel leak. It cannot see your publication list, your live policies, or whether a given subscriber got an event. The catalog queries above are the authority there.
FAQ
Does Supabase Realtime respect RLS?
Yes, for Postgres Changes — every event is authorized against each subscriber's policies, so users only receive changes to rows they can read. The exception is DELETE: RLS can't check a row that no longer exists, so every subscriber listening for deletes on that table gets the event. (A filter on DELETE events only works if the table has replica identity full.)
Why did Realtime stop working after I enabled RLS?
Almost certainly because the subscribing role has no policy granting SELECT on those rows. Enabling RLS with no policies denies everything by default. Add a SELECT policy scoped to auth.uid().
Do I need to add tables to a publication for Realtime?
Yes. Only tables in the supabase_realtime publication emit Postgres Changes. Without it you get no events regardless of your policies.
Are Broadcast messages protected by table RLS?
No. Broadcast and Presence don't read your tables. Use a private channel and write policies on realtime.messages to control who can join a topic.
Is replica identity full a data leak with RLS?
Not for the old record on an RLS-protected table — Supabase sends only the primary keys in that case. It does mean sensitive values in a primary key would be exposed on delete events, so keep keys opaque.
See which of your Supabase tables anyone can read, free
Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.
Check my Supabase projectPrefer to check code instead? Scan your repo, no signup.