← All posts
·7 min read·GuardLayer

Supabase Realtime not working with RLS?

SupabaseRLSRealtimePostgres

Supabase Realtime authorizes every Postgres Changes event against each subscriber's RLS policies — if a user can't SELECT a row, they never receive its event. So "Realtime stopped working after I enabled RLS" almost always means one of three things: the table isn't in the supabase_realtime publication, the subscriber's role lacks a SELECT grant or a matching policy, or you're on a private channel with no policy on realtime.messages.

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. By default the old record contains only the primary key. If you set replica identity full to get complete old rows, Supabase's docs note that when RLS is enabled the old record still contains "only the primary key(s)" — so enabling replica identity full on an RLS-protected table does not leak the deleted row's contents.

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 — you have to disconnect them. Plan for that if membership changes need to take effect immediately.

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, which can't be filtered.

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.

Catch this before it ships — free

GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading