メインコンテンツへスキップ
友田 陽大
データベース・RLS
Supabase
RLS
PostgreSQL
リアルタイム
セキュリティ

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

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

公開日
読了時間
10分
著者
友田 陽大
シェア

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

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

この記事は、Supabase Realtimeの3機能——Broadcast(任意メッセージ)・Presence(在席共有)・Postgres Changes(DB変更通知)——を、RLSで正しく認可する方法を解説します。題材はリアルタイム試合記録アプリ(複数人が同じ試合を同時にスコアリングする、全RLS・280ポリシー運用)——リアルタイム認可が本当に効いていないと事故になるプロダクトです。内容はSupabase公式PostgreSQL RLS公式(2026年6月時点)に忠実です。

前提:RLSの基礎(USING/WITH CHECK・ロール・(select)最適化)はRLS入門性能最適化で扱いました。本記事はそれを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を渡します。これを忘れると認可レイヤーが働かず、ルームが意図せず緩くなります。

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 トピックを認証済みユーザーが受信してよい」:

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

(select realtime.topic()) と包むのは、行ごとの再評価を避ける性能最適化です。実運用ではトピックを固定値にせず、メンバーシップと突き合わせます(次章)。


4. Broadcast:受信(SELECT)と送信(INSERT)を別々に許可する

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

-- 受信:メンバーは、自分が所属するトピックの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と同じです。

-- 在席の受信:メンバーは同室の在席状況を見られる
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にできます(公式も同パターンを示しています)。条件が分岐するなら分け、共通なら束ねる——重複は実体があるときだけ抽出します。

-- 条件が同じなら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)。

// 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)。

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

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

// トークンが更新されたら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 の許可/拒否を固定し、CIで退行を止めてください。

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

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

これでデータ(テーブル)・ファイル(Storage)・リアルタイム(本記事)の3面すべてを、同じRLSの思想で一貫して守れます。「アプリの善意」ではなく「DBの構造」で認可を守る——それがSupabaseでゼロトラストを実現する道です。

一次情報(必ず最新を確認してください)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

お困りごとはありませんか?

設計から実装・運用まで、一人 × 生成AI で伴走します

この記事のような実装を、要件定義から本番運用まで一気通貫で。まずは30分の無料技術相談から、状況をお聞かせください。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい