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