リアルタイム機能は、認可を最も忘れやすい場所です。データベースのテーブルには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 / 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)・リアルタイム(本記事)の3面すべてを、同じRLSの思想で一貫して守れます。「アプリの善意」ではなく「DBの構造」で認可を守る——それがSupabaseでゼロトラストを実現する道です。