Article · Apr 17, 2026

Realtime broadcast scope is a security boundary, not a routing convenience

Default-public Realtime broadcasts leak message bodies to every subscriber. The private-channel flag is the fix; here is when to use it.

A security review flagged it. An admin user subscribed to the channel a regular user was publishing on. Every time the regular user updated their dashboard state, the admin saw the full payload. Names, status, internal IDs that should not have left the regular user’s session. Nothing in the admin’s UI surfaced the data; it was just sitting in the WebSocket frames, observable in DevTools.

The fix was four lines (the is_private: true flag plus one RLS policy). The decision that made the fix necessary was three months old: when the team set up Realtime, they picked the default channel-name pattern and never came back to think about who could subscribe.

This post is about why Realtime broadcast scope is a security boundary, not a routing convenience, and the discipline that closes the gap.

What Realtime broadcasts actually do

Supabase Realtime gives you three features over WebSockets: Broadcast (pub-sub messages between clients), Presence (who is currently subscribed), and Postgres Changes (database events streamed to subscribers). The one with the biggest security surface is Broadcast.

The model is simple. A client subscribes to a channel by name (any string, e.g. room-23 or user-notifications:550e8400-e29b-41d4-a716-446655440000). Any client can publish to any channel name. Any client can subscribe to any channel name. The server fans out every published message to every subscriber.

That last sentence is the security model in the default case. There is no per-message authorization. There is no check on whether the subscriber should be allowed to read what the publisher sent. The channel name is a routing label, not an access control list.

For a public chat room or a multiplayer game lobby, this model is exactly right. For anything per-user or per-tenant, it is a leak waiting to be found.

The default that leaks

The most common shape of the bug I see in inherited Supabase apps:

// Client A (the user): publishes timer updates
const channel = supabase.channel('timer-updates');
channel.subscribe();
channel.send({
  type: 'broadcast',
  event: 'tick',
  payload: { userId, projectId, taskName, elapsedMs }
});

// Client B (the admin): subscribes to the same channel to "watch activity"
const channel = supabase.channel('timer-updates');
channel.on('broadcast', { event: 'tick' }, ({ payload }) => {
  // Receives EVERY tick from EVERY user. Names, project IDs, task descriptions.
});
channel.subscribe();

The intent on the admin side was “show me a dashboard of activity across the team.” The implementation works. Every user’s tick event lands in the admin’s subscription, payload included. So far so good for the dashboard.

What the team did not realize is that any client subscribed to timer-updates gets the same firehose. The admin client. A second user’s client. A malicious client that opens DevTools, types supabase.channel('timer-updates').subscribe(), and dumps the messages to a file. The channel name is the routing label, not a permission.

A diagram showing five clients (admin, user A, user B, user C, attacker) all subscribed to the same channel name "timer-updates" with arrows from each publisher branching out to every subscriber, including the attacker. Below: a red callout that says "channel name is routing, not access control."

When per-user subscription scope is the right answer

The question to ask before any channel ships: “would I be okay if a random other authenticated user subscribed to this channel name and read every message on it?”

For genuinely shared channels (chat room, lobby, announcement feed), the answer is yes. Default-public is right.

For per-user channels (notifications, account-specific updates, in-progress workflow state), the answer is no. The channel needs scoping.

The scoping has two parts. The channel name encodes the scope (user-notifications:<user-id>), and an RLS policy enforces that only the matching user can subscribe.

The is_private: true flag and how it changes the wire

The flag goes on the channel constructor:

const channel = supabase.channel(
  `user-notifications:${userId}`,
  { config: { private: true } }
);

When private: true is set, Supabase Realtime authorizes every subscription and every published message against an RLS policy on the realtime.messages table. The policy runs in a Postgres transaction with the subscriber’s JWT loaded (so auth.uid() resolves to the subscriber), and the result determines whether the WebSocket frame is delivered.

The RLS policy you write for the per-user pattern:

create policy "users can read their own notification channel"
  on realtime.messages
  for select
  to authenticated
  using (
    extension = 'broadcast'
    and (
      -- Extract the user-id segment from "user-notifications:<uuid>"
      split_part(topic, ':', 2) = (select auth.uid()::text)
    )
  );

create policy "users can write to their own notification channel"
  on realtime.messages
  for insert
  to authenticated
  with check (
    extension = 'broadcast'
    and split_part(topic, ':', 2) = (select auth.uid()::text)
  );

Two policies, one for SELECT (subscribe and read) and one for INSERT (publish). Both compare the user-id segment of the channel topic against the authenticated user’s id. The server filters every message at publish time and at read time.

Now if a malicious client tries supabase.channel('user-notifications:550e8400-...').subscribe() with a UUID they did not own, the subscription is rejected. Nothing reaches their WebSocket. The leak channel is closed at the server.

This is the same access-control discipline I covered for the two-layer identity model: authentication tells you who the user is, authorization decides what they can see. The private-channel flag plus an RLS policy makes Realtime obey the same chain.

When you still want default-public

Three cases where leaving the private flag off is correct:

Public announcements. A channel like system-status that broadcasts maintenance notices to every signed-in user. There is no per-user scoping; everyone should see every message.

Lobbies and matchmaking rooms. A game-lobby:42 channel where the whole point is that any player can join, see who else is there, and chat. The channel name is the room; access is implicit by joining.

Presence-only signals. Sometimes you want to know “is anyone watching this dashboard right now” without exchanging payload. Presence-only on a public channel is fine when no message body carries sensitive data.

The rule is: if the channel’s payload would survive a screenshot ending up on Twitter, default-public is fine. If the payload would not, you need the private flag.

Threat-modeling Realtime in your own app

The audit is short:

  1. Inventory every supabase.channel(...) call in the client code. Note the channel name pattern (static name like room-23 vs templated like user-notifications:<uuid>).
  2. For each channel, ask: “would a random authenticated user reading every message on this channel be okay?”
  3. For every “no”, add the private: true flag, write an RLS policy on realtime.messages, and verify the policy by attempting to subscribe with a different test user and confirming the subscription fails.
  4. Document the channel-name conventions so future developers do not introduce a new channel and forget the scoping. A single line in CLAUDE.md or a code comment naming the convention is enough.

The verification step matters. RLS policies are easy to write incorrectly (off-by-one errors on split_part indices, missing the to authenticated clause, wrong column reference). The test you actually want is “open two browser tabs as different users and try to subscribe to the other user’s channel.” If the second tab receives any message, the policy is broken.

Where the boundary breaks down

Two failure modes that are not in the docs but show up in practice:

Wildcards in channel names are easy to misuse. If your topic is tenant:<tenant-id> and a tenant id is a small integer, an attacker can subscribe to every tenant by guessing the integers. Use UUIDs for tenant ids if the channel name leaks the value. (UUIDs are unguessable; sequential integers are not. The same rule applies to URL parameters; I covered that in user enumeration via password reset.)

Authorization caching can hide policy changes. Supabase Realtime caches the RLS evaluation per client per channel. If you tighten a policy and a malicious subscriber is already connected, the cache may serve them for the duration of the WebSocket. Disconnect-and-reconnect to refresh. For high-sensitivity channels, consider rotating the channel name (and the underlying scope token) periodically.

What this does not solve

The private-channel flag scopes who can subscribe and who can publish on a channel. It does not:

  • Encrypt the payload end-to-end. Supabase sees the message body. If you need true E2E privacy, encrypt the payload in the client and decrypt in the receiving client.
  • Audit who subscribed. If you need a record of “user X read this message at time Y,” log it from the receiving client or layer your own audit on top.
  • Prevent denial-of-service via message flooding. Rate-limit at the publishing end.

For the related “compliance trail” requirement, the audit log tutorial in this series covers Postgres-level capture of every change to sensitive tables. Realtime authorization handles the live channel; the audit log handles the historical record.

If you are running a Supabase product with Realtime channels that carry per-user data, this is one of the four security audits I run on every inherited project. The other three in the same series are the two-layer identity model, the PostgREST upsert fragility, and the Origin-validation patch on edge functions.

If you want a second pair of eyes on the Realtime authorization model before the next compliance review or the next batch of users, let’s talk. The audit is about three hours and covers everything in this post plus the related policies in the same series.

Frequently asked questions

What does the Supabase Realtime private-channel flag do?

Setting `is_private: true` on a channel tells Supabase Realtime to enforce RLS (Row-Level Security) policies on the `realtime.messages` table for every message published to or read from that channel. Without the flag, every subscriber to the channel name receives every message. With the flag plus an RLS policy that says "only the publishing user and admins can read this channel," Supabase filters at the server side so unauthorized subscribers never see the message bytes. The flag is opt-in; default channels are public.

Can I scope a channel to a specific user via RLS?

Yes. Create an RLS policy on `realtime.messages` that checks the channel topic against the authenticated user's id. A common shape is `using (split_part(topic, ':', 2) = auth.uid()::text)` for channel topics like `user-notifications:550e8400-...`. The policy runs in a Postgres transaction with the user's JWT claims loaded, so `auth.uid()` resolves to the subscribing user. Each user can only subscribe to their own channel.

Is presence (online status) affected by the private flag?

Presence (who is currently subscribed to a channel) has its own authorization layer. On a private channel, you can publish presence events that only authorized subscribers receive. Treat presence the same way as broadcast: if it carries identifying information (usernames, statuses, locations), assume default-public exposes it and wrap with private mode plus an RLS policy.

How does this compare to WebSocket scoping in Socket.IO?

Socket.IO uses rooms, where the server explicitly controls who joins which room and filters messages at emission time. Supabase Realtime's default model is closer to a topic bus: clients subscribe to a topic by name, and the server fans out every published message. The private-channel flag plus RLS turns Supabase Realtime into something closer to the Socket.IO room model, with the authorization expressed declaratively in Postgres rather than imperatively in server code.

Should I use Realtime broadcasts for sensitive data at all?

Yes, but only with the private flag and an explicit RLS policy. If the data is genuinely per-user (notifications, timer updates, presence in a personal workspace), the private flag works well and the implementation is clean. If the data is "many users see updates with subtle scoping rules" (a multi-tenant marketplace where vendors see their own orders but admins see all), the RLS policies get complex enough that you should consider polling-based fetch over Realtime to keep the authorization model in your normal RLS chain.