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 plus RLS is the fix.
A security review flagged it. An admin user was subscribed to the channel a regular user published on. Every time the regular user updated their dashboard state, the admin saw the full payload: names, status, internal IDs that should never have left the regular user’s session. Nothing in the admin’s UI surfaced the data. It was just sitting in the WebSocket frames, visible 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, from when the team set up Realtime, picked the default channel-name pattern, and never came back to ask who could subscribe.
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).
Broadcast channel: a named pub-sub topic in Supabase Realtime. Any client can subscribe to a channel by string name and receive every message published to it. The channel name is a routing label, not an access control list.
The model is direct. A client subscribes to a channel by name (any string: room-23, 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 entire security model in the default case. No per-message authorization. No check on whether the subscriber should be allowed to read what the publisher sent.
For a public chat room or a multiplayer game lobby, this 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.
What the team did not realize: 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 routing, not permission.

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?”
For genuinely shared channels (chat room, lobby, announcement feed): yes. Default-public is right.
For per-user channels (notifications, account-specific updates, in-progress workflow state): no. The channel needs scoping. That 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
RLS (Row-Level Security): a Postgres feature that attaches a
USINGexpression to a table, evaluated per row per query. When a client reads or writes a row, Postgres checks the expression against that client’s JWT claims. Rows that fail the check are invisible to the client.
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. The result determines whether the WebSocket frame is delivered.
The RLS policy 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: SELECT (subscribe and read) and 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.
This is the same authorization 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 RLS makes Realtime obey the same chain.
When default-public is correct
Public announcements. A system-status channel that broadcasts maintenance notices to every signed-in user. No per-user scoping; everyone should see every message.
Lobbies and matchmaking rooms. A game-lobby:42 channel where 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 where payload carries no sensitive data. Sometimes knowing “is anyone watching this dashboard right now” is all you need.
If the channel’s payload would survive a screenshot ending up on Twitter, default-public is fine. If it would not, use the private flag.
Threat-modeling Realtime in your own app
Inventory every supabase.channel(...) call in the client code. Note whether the channel name is static (like room-23) or templated (like user-notifications:<uuid>).
For each channel, ask: “would a random authenticated user reading every message be okay?”
For every “no”: add the private: true flag, write an RLS policy on realtime.messages, and verify by attempting to subscribe with a different test user. The test you actually want is two browser tabs as different users. If the second tab receives any message, the policy is broken.
Document the channel-name conventions somewhere the next developer will find them. A single line in a CLAUDE.md or a code comment naming the convention is enough. The pattern is easy to break silently when someone adds a new channel weeks later.
The verification step is the one most teams skip. RLS policies are easy to write incorrectly: off-by-one errors on split_part indices, missing the to authenticated clause, wrong column reference. Don’t trust a test that only checks the happy path.
Where the boundary breaks down
Two failure modes that don’t appear in the docs but show up in production:
Wildcards in channel names. If your topic is tenant:<tenant-id> and the tenant id is a small integer, an attacker can subscribe to every tenant by guessing integers. Use UUIDs for tenant ids if the channel name contains 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. 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 connection. Disconnect-and-reconnect forces a refresh. For high-sensitivity channels, consider rotating the channel name periodically.
What the private flag does not solve
The private-channel flag scopes who can subscribe and who can publish. It does not encrypt the payload end-to-end (Supabase sees the message body), provide an audit log of who subscribed (you’d need to layer that from the receiving client), or prevent message flooding (rate-limit at the publishing end).
For the 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.
The other three audits in the same series: the two-layer identity model, PostgREST upsert fragility, and origin-validation 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 covers everything here plus the related policies, and takes about three hours.
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 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 scoped to the publishing user, Supabase filters at the server side so unauthorized subscribers never see the message bytes. Default channels are public; the flag is opt-in.
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 has its own authorization layer. On a private channel, presence events are only delivered to authorized subscribers. Treat presence the same as broadcast: if it carries identifying information (usernames, statuses, locations), default-public exposes it and you need the private flag 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 and filters 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 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 per-user (notifications, timer updates, presence in a personal workspace), the private flag works cleanly. If the data involves complex scoping across tenants (vendors see their own orders, admins see all), the RLS policies get complex enough that polling-based fetch may keep the authorization model simpler and more auditable.