Storage and Realtime
Authorize Realtime channels
Use private channels, topic design, RLS-backed authorization, cleanup, and reconnect behavior without leaking events across tenants.
A channel topic is part of the authorization boundary. room:42 is not a decorative label. It is the value your policies inspect to decide who may join. Use stable tenant or room identifiers, never guessable names built from emails or usernames.
By default, a channel is open to any client that knows the topic. Mark it private so Realtime checks authorization when a client tries to join:
const channel = supabase.channel('room:42', {
config: { private: true },
})
Private channels are authorized with RLS policies on the realtime.messages table. Same tool as notes and files. A select policy controls who may join and receive. An insert policy controls who may send.
Check membership against your own data:
create policy "members receive room events"
on realtime.messages for select
to authenticated
using (
exists (
select 1 from room_members
where room_members.room_id =
split_part(realtime.topic(), ':', 2)::bigint
and room_members.user_id = auth.uid()
)
);
realtime.topic() returns the topic the client asked to join. split_part pulls the 42 out of room:42. The policy then looks for a row in room_members, so membership is the source of truth for who hears what. It is durable, you can query it, and you can revoke it with a delete.
Test with two rooms and two users
Ada is a member of room 42, Grace is not. Ada’s subscribe() callback reports SUBSCRIBED:
channel.subscribe(status => {
console.log(status)
// SUBSCRIBED for Ada, CHANNEL_ERROR for Grace
})
Grace gets an error status instead of silently joining. If Grace gets in anyway, check that the client really passed private: true. Forgetting that flag is the common hole, and nothing looks wrong, because members still connect fine.
Clean up and expect messiness
Remove subscriptions when a client leaves:
await supabase.removeChannel(channel)
A leaked subscription keeps receiving events and keeps consuming connection quota. In a UI component it double-handles every message after a remount, and you end up debugging duplicate chat bubbles that are really a missing cleanup call.
Then design for what the transport actually promises. Expect reconnects, duplicates, gaps, and effects applied out of order. A client that was offline for ten seconds missed whatever was broadcast during them, because Realtime does not replay history.
My advice is to keep durable truth in a database table and treat channel events as hints. On reconnect, refetch the state from the table, then resume listening. A chat that refetches the last fifty messages on reconnect feels solid. One that trusts the stream alone shows holes.
Lesson completed