# Supabase RealtimeをRLSで認可する：Broadcast・Presence・プライベートチャンネルを安全に設計する

> Supabase Realtimeの認可を、realtime.messagesテーブルへのRLSで設計する実装ガイド。private:trueとsetAuthでプライベートチャンネルを有効化し、realtime.topic()とextension（broadcast/presence）で『そのルームのメンバーだけが送受信できる』を表現、postgres_changesは対象テーブル自身のRLSを尊重する仕組み、JWT失効と再認証、(select)ラップの最適化まで、公式準拠の実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, リアルタイム, セキュリティ
- URL: https://tomodahinata.com/blog/supabase-realtime-rls-authorization-broadcast-presence-private-channel-guide
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- Realtimeの認可はrealtime.messagesテーブルへのRLSで決まる。private:trueのチャンネルに接続すると、JWTクレーム・topic・extensionを基にポリシーが評価される
- realtime.topic()が接続先トピック、extension列が'broadcast'/'presence'を表す。『そのルームのメンバーか』をrooms_usersとtopicで突き合わせ、送信はINSERT(WITH CHECK)・受信はSELECT(USING)で別々に許可する
- クライアントはchannel(config:{private:true})で接続し、supabase.realtime.setAuth()でJWTを渡す。private指定を忘れると認可されず、機密ルームが意図せず緩くなる
- Postgres Changesは別機構で、対象テーブル自身のRLSを尊重する——読めない行の変更イベントは届かない。Broadcast/PresenceのrealtimemessagesRLSとは層が違う
- ポリシーは接続中キャッシュされ、新しいaccess_tokenで更新される。JWTが失効前に更新されないと切断。auth.uid()/realtime.topic()は(select)で包んで再評価を避ける

---

リアルタイム機能は、認可を**最も忘れやすい**場所です。データベースのテーブルにはRLSを丁寧に書いたのに、「ライブのチャットやカーソル共有」を足した瞬間、**ルームのトピック名さえ知っていれば部外者が会話を傍受・発言できる**——これは実際によくある穴です。なぜなら、リアルタイムのメッセージは**普通のテーブルクエリを通らない**ので、テーブルのRLSが効かないからです。

Supabaseはこの問題に明快な答えを用意しています。**Realtimeの認可は、`realtime.messages` という専用テーブルへのRLSで決まる**（[Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization)）。ユーザーがチャンネルに接続すると、その権限が**RLSポリシー・JWTクレーム・接続先のトピック**を基に計算される。つまり、データベースで使ってきたRLSの考え方が、そのままリアルタイムにも効くのです。

この記事は、Supabase Realtimeの3機能——**Broadcast（任意メッセージ）・Presence（在席共有）・Postgres Changes（DB変更通知）**——を、RLSで正しく認可する方法を解説します。題材は[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)（複数人が同じ試合を同時にスコアリングする、全RLS・280ポリシー運用）——リアルタイム認可が**本当に効いていないと事故になる**プロダクトです。内容は[Supabase公式](https://supabase.com/docs/guides/realtime/authorization)・[PostgreSQL RLS公式](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)（2026年6月時点）に忠実です。

> **前提**：RLSの基礎（USING/WITH CHECK・ロール・`(select)`最適化）は[RLS入門](/blog/supabase-rls-getting-started-enable-first-policy-guide)と[性能最適化](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)で扱いました。本記事はそれをRealtime（`realtime.messages`）に適用します。

---

## 1. 全体像：3つの機能と「2つの認可機構」

混乱しやすいので最初に整理します。Supabase Realtimeには機能が3つ、認可の機構が**2つ**あります。

| 機能 | 何をするか | 認可の機構 |
| --- | --- | --- |
| **Broadcast** | 任意の低遅延メッセージ（チャット・カーソル等） | `realtime.messages` への**RLS** |
| **Presence** | 誰が今オンラインかの共有 | `realtime.messages` への**RLS** |
| **Postgres Changes** | テーブルのINSERT/UPDATE/DELETE通知 | **対象テーブル自身のRLS** |

- **Broadcast / Presence** は、`realtime.messages` テーブルへのRLSで「このトピックを送受信してよいか」を判定します（§3〜§5）。
- **Postgres Changes** は別物で、**変更が起きたテーブル自身のRLS**を尊重します。読めない行の変更イベントは届きません（§6）。

この「層が違う」を最初に掴むと、設計がぶれません。

---

## 2. プライベートチャンネルを有効にする（クライアント側）

`realtime.messages` のRLSが効くのは、**プライベートチャンネル**に接続したときです。クライアントで `private: true` を指定し、`setAuth()` でユーザーのJWTを渡します。これを忘れると認可レイヤーが働かず、ルームが意図せず緩くなります。

```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 }` が認可スイッチです。これがないチャンネルはRLSの評価対象になりません。**機密性のあるルームは必ず `private: true`** にしてください。

---

## 3. トピックとextension：ポリシーの2つの軸

`realtime.messages` へのRLSを書くとき、判定に使う2つの値があります。

- **`realtime.topic()`** — ユーザーが接続しようとしている**チャンネルのトピック**（上の例なら `"room:match-42"`）を返す関数。「どのルームか」を表す。
- **`extension` 列** — そのメッセージが `'broadcast'` か `'presence'` かを表す。「どの機能か」を表す。

この2軸で「**このトピックの、この機能を、このユーザーが使ってよいか**」を表現します。JWTクレームは `auth.uid()` や `current_setting('request.jwt.claims')::json` で参照できます。

最小の例（公式）——「`room-1` トピックを認証済みユーザーが受信してよい」:

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

`(select realtime.topic())` と包むのは、[行ごとの再評価を避ける性能最適化](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)です。実運用ではトピックを固定値にせず、**メンバーシップと突き合わせ**ます（次章）。

---

## 4. Broadcast：受信（SELECT）と送信（INSERT）を別々に許可する

実アプリの肝は、**「そのルームのメンバーだけが送受信できる」**です。ルームのメンバーを `rooms_users(user_id, room_topic)` のようなテーブルで管理し、RLSで突き合わせます。**受信は `SELECT`（USING）、送信は `INSERT`（WITH CHECK）**で別々に書くのが要点です——[USINGとWITH CHECKの役割分担](/blog/supabase-rls-with-check-using-write-bypass-guide)はここでも同じです。

```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')
  )
);
```

**なぜ送受信を分けるのか**：要件は非対称になりがちだからです。「全員が読めるが、発言できるのは司会だけ」「観戦者は受信のみ、スコアラーは送信可」——SELECTとINSERTを別ポリシーにしておけば、こうした差を自然に表せます。私の試合記録アプリでは、**スコアラーはスコア更新をbroadcastできるが、観戦者は受信専用**という区別をこの形で実装しました。

`rooms_users(user_id, room_topic)` には**複合索引**を張り、`exists` の判定を索引一発にします。

---

## 5. Presence：在席情報も同じ枠組みで

Presence（「誰が今このルームにいるか」）も `realtime.messages` のRLSで、`extension in ('presence')` を使って認可します。構造は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')
  )
);
```

BroadcastとPresenceを**同じ条件で許す**なら、`extension in ('broadcast', 'presence')` と1本にまとめてDRYにできます（公式も同パターンを示しています）。条件が分岐するなら分け、共通なら束ねる——**重複は実体があるときだけ抽出**します。

```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：対象テーブル自身のRLSが効く

3つ目の機能 **Postgres Changes**（テーブルの変更をリアルタイム通知）は、**機構が違います**。`realtime.messages` ではなく、**変更が起きたテーブル自身のRLS**が評価されます。Supabase公式の言葉では「RLS有効テーブルのPostgres Changesでは、**そのテーブルの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();
```

ここが綺麗な点です。**`matches` テーブルに `using ((select auth.uid()) = user_id)` のRLSを書いてあれば、他人の試合の変更イベントは届きません。** 新しい認可を書く必要はなく、テーブルのRLSがそのままリアルタイムにも効く。**「DBで一度守れば、リアルタイムでも守られる」**——これがSupabaseのRLS中心設計の強さです。

ただし注意：Postgres Changesは規模が大きくなると、各クライアントごとにRLSを評価するコストが効いてきます。大量の同時接続では、**DB変更をサーバーで受けてBroadcastに変換**する設計（`realtime.broadcast_changes`）が推奨されることがあります。要件規模で選んでください。

---

## 7. 運用の勘所：JWT失効・再認証・ポリシーキャッシュ

リアルタイムは**接続が長時間維持される**ため、トークンの寿命管理が普通のリクエストより重要です。公式の挙動を押さえます（[Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization)）。

- **ポリシーは接続中キャッシュされる**。毎メッセージで再評価はされない。
- **新しいJWTを `access_token` メッセージで送ると更新される**。
- **JWTが失効する前に新しいトークンが届かないと、クライアントは切断される**。

したがって、長時間の接続では**トークンのリフレッシュに合わせて `setAuth()` を呼び直す**運用にします。`@supabase/ssr` 経由でトークンが更新されたら、Realtimeにも新トークンを渡す。

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

「最初は動いていたのに数十分後にリアルタイムが切れる」の典型原因がこれです。**接続の寿命＞トークンの寿命**になる前に更新する、と覚えてください。

### パフォーマンスと正しさ

- ポリシー内の `auth.uid()`・`realtime.topic()` は **`(select ...)` で包む**（再評価回避）。
- `rooms_users(user_id, room_topic)` に**複合索引**を張る。
- そして最後は——**「メンバーは送受信できる／部外者は拒否される」をテスト**する。リアルタイムの認可こそ、デモでは「動いた」ように見えて部外者の傍受に気づきにくい。[pgTAPで `realtime.messages` の許可/拒否を固定](/blog/supabase-rls-testing-pgtap-policy-regression-guide)し、CIで退行を止めてください。

---

## まとめ：リアルタイムも「RLSで守る」

- Realtimeの **Broadcast / Presence** は **`realtime.messages` へのRLS**で認可する。クライアントは **`private: true` ＋ `setAuth()`** で接続する。
- 判定軸は **`realtime.topic()`（どのルーム）** と **`extension`（broadcast/presence）**。メンバーシップと突き合わせ、**受信＝SELECT(USING)／送信＝INSERT(WITH CHECK)** を別々に許可する。
- **Postgres Changes** は別機構で、**対象テーブル自身のRLS**を尊重する。DBで守れば、変更通知でも守られる。
- 接続は長寿命。**JWT失効前に `setAuth()` で更新**しないと切断される。`(select)`ラップと索引で速く、**拒否テスト**で安全に。

これでデータ（テーブル）・ファイル（[Storage](/blog/supabase-storage-rls-access-control-bucket-folder-policies-guide)）・リアルタイム（本記事）の3面すべてを、同じRLSの思想で一貫して守れます。**「アプリの善意」ではなく「DBの構造」で認可を守る**——それがSupabaseでゼロトラストを実現する道です。

### 一次情報（必ず最新を確認してください）

- [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)
