Realtime authorization

Restrict who can join, read and send on Supabase Realtime channels on SupaHost using private channels and RLS policies on realtime.messages, with a team chat example.

By default a Realtime channel is public. Anyone who has your project's anon key (which is in your frontend code) and knows a channel's name can join it, read its Broadcast messages and Presence list, and send to it. That's acceptable for public data, but not for a team's chat or a customer's order updates.

Private channels fix this. When a client joins a channel with private: true, Realtime checks RLS policies on the realtime.messages table in your project's database, as the signed-in user, before letting it in.

Postgres Changes is authorized differently: it uses the RLS policies on the table you subscribe to. This page is about Broadcast and Presence.

How the check works

  • Joining and receiving needs a select policy on realtime.messages that passes for the user and the topic.
  • Sending Broadcast messages or tracking Presence needs an insert policy.
  • Inside a policy, realtime.topic() returns the channel topic being joined, and the extension column says whether the check is for 'broadcast' or 'presence'.
  • Realtime runs the check when the client joins, then caches the result for that connection. If a user loses access, they keep it on that connection until they rejoin or their token expires.

The policies apply to the authenticated role (or anon, if you want guests on some private channels).

Example: team chat

A team chat app has teams, members, and one channel per team, named team:<team id>.

create table public.team_members (
  team_id uuid not null,
  user_id uuid not null references auth.users (id) on delete cascade,
  primary key (team_id, user_id)
);
create index on public.team_members (user_id);
alter table public.team_members enable row level security;

create policy "Members see their own memberships"
on public.team_members for select to authenticated
using (user_id = (select auth.uid()));

Now let members receive and send on their team's channel:

create policy "Team members can receive on their team channel"
on realtime.messages for select to authenticated
using (
  realtime.messages.extension in ('broadcast', 'presence')
  and exists (
    select 1 from public.team_members m
    where m.user_id = (select auth.uid())
      and 'team:' || m.team_id::text = (select realtime.topic())
  )
);

create policy "Team members can send on their team channel"
on realtime.messages for insert to authenticated
with check (
  realtime.messages.extension in ('broadcast', 'presence')
  and exists (
    select 1 from public.team_members m
    where m.user_id = (select auth.uid())
      and 'team:' || m.team_id::text = (select realtime.topic())
  )
);

Wrapping auth.uid() and realtime.topic() in (select ...) makes Postgres evaluate them once per check. The index on team_members (user_id) keeps the lookup fast as teams grow.

If you see must be owner of table messages while creating these policies, see must be owner of table messages.

Join a private channel from the client

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)

await supabase.auth.signInWithPassword({ email, password })

// Make sure Realtime uses the signed-in user's token
await supabase.realtime.setAuth()

const teamChannel = supabase
  .channel(`team:${teamId}`, { config: { private: true } })
  .on('broadcast', { event: 'message' }, ({ payload }) => appendMessage(payload))
  .subscribe((status, err) => {
    if (status === 'CHANNEL_ERROR') {
      console.error('Not allowed on this channel, or the connection failed', err)
    }
  })

If the policy denies the user, the subscription fails with CHANNEL_ERROR instead of reaching SUBSCRIBED. A user signed in to the wrong account, a missing membership row, or a topic name that doesn't match the policy's pattern are the usual causes.

Read-only channels

To let users receive but not send, for example announcements or order status updates that only your backend publishes, add only the select policy. Your server sends with the service_role key, which bypasses RLS:

// On the server
const admin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)
await admin.channel(`order:${orderId}`, { config: { private: true } })
  .httpSend('status', { status: 'out_for_delivery' })

Or send from the database with realtime.send(..., true) in a trigger. See Broadcast.

Separate rules for Broadcast and Presence

Because the policies can read extension, you can treat the two differently. For instance, let any signed-in customer see who from support is online, but only staff track their presence:

create policy "Signed-in users can see support presence"
on realtime.messages for select to authenticated
using (
  (select realtime.topic()) = 'support:online'
  and realtime.messages.extension = 'presence'
);

create policy "Only staff can track support presence"
on realtime.messages for insert to authenticated
with check (
  (select realtime.topic()) = 'support:online'
  and realtime.messages.extension = 'presence'
  and exists (select 1 from public.staff s where s.user_id = (select auth.uid()))
);

Public channels still work

Making some channels private doesn't change public ones. Any channel joined without private: true skips these policies. Keep sensitive data on private channels only, and don't rely on hard-to-guess channel names for security.

Also keep in mind that a message sent to a private topic only reaches clients that joined that topic as private, and a public message only reaches public channels.

Tokens and expiry

Realtime authenticates with the user's access token. When supabase-js refreshes the session, it passes the new token to Realtime. If you use your own token handling, call supabase.realtime.setAuth(token) after each refresh, or the channel fails when the old token expires. Access token lifetime is set in Dashboard → your project → Auth tab → Sign-in & security → Access token lifetime. See Sessions, JWTs and refresh tokens.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us