# Authorizing Supabase Realtime with RLS: safely designing Broadcast, Presence, and private channels

> An implementation guide that designs Supabase Realtime authorization with RLS on the realtime.messages table. With official-compliant real code, it explains: enabling a private channel with private:true and setAuth, expressing 'only members of that room can send/receive' with realtime.topic() and the extension (broadcast/presence), the mechanism by which postgres_changes respects the target table's own RLS, JWT expiry and re-authentication, and the (select) wrap optimization.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, PostgreSQL, リアルタイム, セキュリティ
- URL: https://tomodahinata.com/en/blog/supabase-realtime-rls-authorization-broadcast-presence-private-channel-guide
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- Realtime authorization is determined by RLS on the realtime.messages table. When you connect to a private:true channel, the policy is evaluated based on JWT claims, the topic, and the extension.
- realtime.topic() is the connected topic, and the extension column represents 'broadcast'/'presence'. Match 'is it a member of that room' with rooms_users and the topic, and permit sending with INSERT (WITH CHECK) and receiving with SELECT (USING) separately.
- The client connects with channel(config:{private:true}) and passes the JWT with supabase.realtime.setAuth(). Forgetting the private designation means no authorization, and a confidential room unintentionally becomes loose.
- Postgres Changes is a separate mechanism that respects the target table's own RLS — change events for rows you can't read don't arrive. It's a different layer from the realtime.messages RLS of Broadcast/Presence.
- The policy is cached during the connection and updated with a new access_token. If it isn't updated before the JWT expires, it disconnects. Wrap auth.uid()/realtime.topic() in (select) to avoid re-evaluation.

---

Realtime features are the place authorization is **most easily forgotten.** You carefully wrote RLS on the database tables, but the moment you add "live chat or cursor sharing," **an outsider who merely knows the room's topic name can eavesdrop on and speak in the conversation** — this is a hole that actually happens often. Because realtime messages **don't go through an ordinary table query**, the table's RLS doesn't take effect.

Supabase has a clear answer to this problem. **Realtime authorization is determined by RLS on a dedicated table called `realtime.messages`** ([Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization)). When a user connects to a channel, that permission is computed based on **the RLS policy, JWT claims, and the connected topic.** In other words, the RLS thinking you've used in the database works for realtime as-is.

This article explains how to correctly authorize Supabase Realtime's three features — **Broadcast (arbitrary messages), Presence (presence sharing), Postgres Changes (DB-change notifications)** — with RLS. The subject is the [real-time match-recording app](/case-studies/realtime-sports-scoring-app) (multiple people scoring the same match simultaneously, all-RLS, 280-policy operation) — a product where, **unless realtime authorization is truly effective, it becomes an accident.** The content is faithful to [Supabase official](https://supabase.com/docs/guides/realtime/authorization) and [PostgreSQL RLS official](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) (as of June 2026).

> **Premise**: the basics of RLS (USING/WITH CHECK, roles, `(select)` optimization) were covered in [RLS for beginners](/blog/supabase-rls-getting-started-enable-first-policy-guide) and [performance optimization](/blog/supabase-rls-performance-optimization-select-wrap-index-guide). This article applies that to Realtime (`realtime.messages`).

---

## 1. The big picture: three features and "two authorization mechanisms"

It's easy to get confused, so let me organize it first. Supabase Realtime has three features and **two** authorization mechanisms.

| Feature | What it does | Authorization mechanism |
| --- | --- | --- |
| **Broadcast** | Arbitrary low-latency messages (chat, cursor, etc.) | **RLS** on `realtime.messages` |
| **Presence** | Sharing who's online now | **RLS** on `realtime.messages` |
| **Postgres Changes** | Table INSERT/UPDATE/DELETE notifications | **The target table's own RLS** |

- **Broadcast / Presence** judge "may you send/receive this topic" with RLS on the `realtime.messages` table (§3–§5).
- **Postgres Changes** is separate and respects **the RLS of the table where the change occurred.** Change events for rows you can't read don't arrive (§6).

Grasping this "different layer" first keeps the design from wobbling.

---

## 2. Enable the private channel (client side)

`realtime.messages` RLS takes effect when you connect to a **private channel.** Specify `private: true` on the client and pass the user's JWT with `setAuth()`. Forget this and the authorization layer doesn't work, and the room becomes unintentionally loose.

```ts
import { createClient } from "@/lib/supabase/client";

const supabase = createClient();

// 認証トークンをRealtimeに渡す（RLSがJWTクレームを見られるようにする）
await supabase.realtime.setAuth(); // セッションのaccess_tokenを使う

const channel = supabase.channel("room:match-42", {
  config: { private: true }, // ← これでrealtime.messagesのRLSが評価される
});

channel
  .on("broadcast", { event: "score" }, (payload) => {
    console.log("score update:", payload);
  })
  .subscribe((status, err) => {
    if (status === "SUBSCRIBED") {
      // 認可された。送信できる
      channel.send({ type: "broadcast", event: "score", payload: { home: 3 } });
    } else if (err) {
      console.error("realtime auth failed:", err); // ポリシーで拒否された等
    }
  });
```

`config: { private: true }` is the authorization switch. A channel without this isn't subject to RLS evaluation. **Always make confidential rooms `private: true`.**

---

## 3. Topic and extension: the policy's two axes

When writing RLS on `realtime.messages`, there are two values used for the judgment.

- **`realtime.topic()`** — a function returning the **channel's topic** the user is trying to connect to (in the example above, `"room:match-42"`). It represents "which room."
- **The `extension` column** — represents whether that message is `'broadcast'` or `'presence'`. It represents "which feature."

With these two axes, express "**may this user use this feature of this topic.**" JWT claims can be referenced with `auth.uid()` or `current_setting('request.jwt.claims')::json`.

A minimal example (official) — "an authenticated user may receive the `room-1` topic":

```sql
create policy "authenticated can read room-1"
on realtime.messages for select to authenticated
using ( (select realtime.topic()) = 'room-1' );
```

Wrapping with `(select realtime.topic())` is [the performance optimization that avoids per-row re-evaluation](/blog/supabase-rls-performance-optimization-select-wrap-index-guide). In real operation, don't make the topic a fixed value — **match it against membership** (next chapter).

---

## 4. Broadcast: permit receiving (SELECT) and sending (INSERT) separately

The crux of a real app is "**only members of that room can send/receive.**" Manage the room's members in a table like `rooms_users(user_id, room_topic)` and match with RLS. The key point is to write **receiving as `SELECT` (USING) and sending as `INSERT` (WITH CHECK) separately** — [the division of USING and WITH CHECK](/blog/supabase-rls-with-check-using-write-bypass-guide) is the same here.

```sql
-- 受信：メンバーは、自分が所属するトピックのbroadcastを受け取れる
create policy "members can receive broadcast"
on realtime.messages for select to authenticated
using (
  exists (
    select 1 from public.rooms_users
    where user_id = (select auth.uid())
      and room_topic = (select realtime.topic())
      and realtime.messages.extension in ('broadcast')
  )
);

-- 送信：メンバーは、自分が所属するトピックにbroadcastを送れる
create policy "members can send broadcast"
on realtime.messages for insert to authenticated
with check (
  exists (
    select 1 from public.rooms_users
    where user_id = (select auth.uid())
      and room_topic = (select realtime.topic())
      and realtime.messages.extension in ('broadcast')
  )
);
```

**Why separate sending and receiving**: because the requirements tend to be asymmetric. "Everyone can read but only the moderator can speak," "spectators receive only, scorers can send" — making SELECT and INSERT separate policies naturally expresses such differences. In my match-recording app, I implemented the distinction that **scorers can broadcast score updates but spectators are receive-only** in this form.

Put a **composite index** on `rooms_users(user_id, room_topic)` to make the `exists` judgment a single index lookup.

---

## 5. Presence: presence info in the same framework

Presence ("who's in this room now") is also authorized with `realtime.messages` RLS, using `extension in ('presence')`. The structure is the same as Broadcast.

```sql
-- 在席の受信：メンバーは同室の在席状況を見られる
create policy "members can read presence"
on realtime.messages for select to authenticated
using (
  exists (
    select 1 from public.rooms_users
    where user_id = (select auth.uid())
      and room_topic = (select realtime.topic())
      and realtime.messages.extension in ('presence')
  )
);

-- 在席の送信：メンバーは自分の在席をトラックできる
create policy "members can track presence"
on realtime.messages for insert to authenticated
with check (
  exists (
    select 1 from public.rooms_users
    where user_id = (select auth.uid())
      and room_topic = (select realtime.topic())
      and realtime.messages.extension in ('presence')
  )
);
```

If you **permit Broadcast and Presence under the same condition**, you can consolidate into one with `extension in ('broadcast', 'presence')` to be DRY (the official docs show the same pattern). Split if the conditions branch, bundle if common — **extract duplication only when it's real.**

```sql
-- 条件が同じなら1本に束ねる（DRY）
create policy "members can listen broadcast and presence"
on realtime.messages for select to authenticated
using (
  exists (
    select 1 from public.rooms_users
    where user_id = (select auth.uid())
      and room_topic = (select realtime.topic())
      and realtime.messages.extension in ('broadcast', 'presence')
  )
);
```

---

## 6. Postgres Changes: the target table's own RLS takes effect

The third feature, **Postgres Changes** (realtime notification of table changes), has a **different mechanism.** Not `realtime.messages`, but **the RLS of the table where the change occurred** is evaluated. In Supabase official's words, "for Postgres Changes on an RLS-enabled table, records are sent **only to clients with read permission via that table's RLS**" ([Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization)).

```ts
// matchesテーブルの変更を購読。届くのは「RLSで自分が読める行」の変更だけ
const channel = supabase
  .channel("db:matches")
  .on(
    "postgres_changes",
    { event: "*", schema: "public", table: "matches" },
    (payload) => console.log("change:", payload),
  )
  .subscribe();
```

This is the clean point. **If you've written RLS like `using ((select auth.uid()) = user_id)` on the `matches` table, change events for others' matches don't arrive.** You don't need to write new authorization; the table's RLS works for realtime as-is. **"Protect once in the DB, and it's protected in realtime too"** — this is the strength of Supabase's RLS-centric design.

But a caution: at scale, Postgres Changes incurs the cost of evaluating RLS per client. For many concurrent connections, a design that **receives DB changes on the server and converts them to Broadcast** (`realtime.broadcast_changes`) is sometimes recommended. Choose by the requirement scale.

---

## 7. Operational key points: JWT expiry, re-authentication, policy caching

Realtime **maintains a connection for a long time**, so token-lifetime management is more important than for ordinary requests. Grasp the official behavior ([Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization)).

- **The policy is cached during the connection.** It isn't re-evaluated on every message.
- **Sending a new JWT in an `access_token` message updates it.**
- **If a new token doesn't arrive before the JWT expires, the client is disconnected.**

So for long connections, operate to **re-call `setAuth()` in step with token refresh.** When the token is updated via `@supabase/ssr`, pass the new token to Realtime too.

```ts
// トークンが更新されたらRealtimeにも反映し、切断を防ぐ
supabase.auth.onAuthStateChange((event, session) => {
  if (session) {
    supabase.realtime.setAuth(session.access_token);
  }
});
```

This is the typical cause of "it worked at first but realtime cuts off after tens of minutes." Remember to update before **the connection's lifetime > the token's lifetime.**

### Performance and correctness

- **Wrap** `auth.uid()` and `realtime.topic()` in the policy **in `(select ...)`** (avoiding re-evaluation).
- Put a **composite index** on `rooms_users(user_id, room_topic)`.
- And finally — **test that "members can send/receive, outsiders are rejected."** Realtime authorization, precisely, looks like it "worked" in a demo, and outsider eavesdropping is hard to notice. [Fix the allow/deny of `realtime.messages` with pgTAP](/blog/supabase-rls-testing-pgtap-policy-regression-guide) and stop regressions in CI.

---

## Conclusion: protect realtime "with RLS" too

- Realtime's **Broadcast / Presence** are authorized with **RLS on `realtime.messages`.** The client connects with **`private: true` + `setAuth()`.**
- The judgment axes are **`realtime.topic()` (which room)** and **`extension` (broadcast/presence).** Match against membership and permit **receiving = SELECT (USING) / sending = INSERT (WITH CHECK)** separately.
- **Postgres Changes** is a separate mechanism that respects **the target table's own RLS.** Protect in the DB and you're protected in change notifications too.
- The connection is long-lived. Without **updating with `setAuth()` before the JWT expires**, it disconnects. Fast with the `(select)` wrap and an index, safe with a **deny test.**

With this, you can protect all three faces — data (tables), files ([Storage](/blog/supabase-storage-rls-access-control-bucket-folder-policies-guide)), and realtime (this article) — consistently with the same RLS philosophy. **Protect authorization with "the DB's structure," not "the app's goodwill"** — that's the path to achieving zero trust on Supabase.

### Primary sources (always confirm the latest)

- [Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization)
- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
