# 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.

Source: https://supahost.app/docs/realtime/authorization · Updated 2026-11-11

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>`.

```sql
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:

```sql
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](/kb/realtime-must-be-owner-messages).

## Join a private channel from the client

```ts
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:

```ts
// 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](/docs/realtime/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:

```sql
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](/docs/auth/sessions).

## Related

- [Realtime TIMED_OUT and CHANNEL_ERROR](/kb/realtime-timed-out)
- [RLS performance best practices](/kb/rls-performance)
- [Row Level Security](/docs/database/row-level-security)
- [Realtime authorization in Supabase's docs](https://supabase.com/docs/guides/realtime/authorization)
