この記事は、自分の制作物 https://github.com/usxc/nextjs15-classroom-library-app (Next.js + Clerk + Prisma + Supabase Realtimeを使用)を題材に、「Supabase Realtime(Broadcast) をどう安全に運用するか」をまとめたものです。
また、このアプリの構成として、DB更新はPrisma経由、Broadcast送信はサーバー経由で行いますが、購読だけはブラウザが直接Supabaseに接続します。
はじめに
図書館...風アプリで、在庫ステータスのリアルタイム反映にSupabase RealtimeのBroadcastを使っていましたが、以前の実装だと誰でも特定のチャンネルに偽Broadcastを送れてしまう構成でした。対策として、RLSでRealtimeを認可し、かつ送信をサーバーサイドに限定した手順を紹介します。
TL;DR
- クライアントで
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYを使ってRealtimeを購読していると、第三者が同じキーでチャンネルに参加&偽Broadcast送信できる可能性がある - 対策として
- Supabase側:Realtime Authorization(
realtime.messagesにRLS) - クライアント側:private channel(
config: { private: true }) - 認証:ClerkセッショントークンをSupabaseに渡してauthenticated扱いにする(Third-party auth)
- 送信:service_role(サーバー)だけ許可にして、クライアント送信を遮断
- Supabase側:Realtime Authorization(
何が問題だったのか
-
以前のコード
-
クライアント(購読側)
components/RealtimeBridge.tsxで、公開キーでクライアントを作りchannel("library")を購読しています。"use client"; import { useEffect } from "react"; import { createClient } from "@supabase/supabase-js"; const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY! ); export function RealtimeBridge({ onUpdate }) { useEffect(() => { const ch = supabase .channel("library") .on("broadcast", { event: "loan:update" }, (msg) => onUpdate(msg.payload)) .subscribe(); return () => supabase.removeChannel(ch); }, [onUpdate]); return null; } -
サーバー(送信側)
貸出/返却APIの最後に
publishLoanEvent()を呼び、lib/realtime.tsがBroadcastを送っています。"use server"; import { createClient } from "@supabase/supabase-js"; const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { auth: { persistSession: false } } ); export async function publishLoanEvent(payload) { const ch = supabase.channel("library"); await ch.send({ type: "broadcast", event: "loan:update", payload }); await supabase.removeChannel(ch); }
-
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYは名前の通り公開前提なので、ブラウザに載ります。すると、「library」というチャンネル名が分かるだけで、第三者が同じキーでチャンネルに参加できる可能性が出ます(Supabase側の設定・認可が弱い場合)。
また、ここで怖いのは「DBを書き換えられる」ではなく、loan:updateの偽Broadcastを送って、全員の画面の在庫ステータスを「貸出中」「返却済み」などに偽装できるという問題です(DBは無事でも、運用が壊れる)。
対策方針
今回の方針はシンプルです。
- クライアントは購読だけ許可
- 送信はサーバ(service_role)だけ許可
- Realtimeチャンネルはprivate channelにして、接続時にRLSで弾く
- クライアントはClerkのセッショントークンをSupabaseに渡してauthenticatedとして扱う
実装手順
1) SupabaseにClerkをThird-party authとして登録
ここは手順だけ書きます。
これで「Clerkのセッショントークンを、Supabaseの認可(RLS)に使う」準備ができました。(以下の画像のようにENABLEDとなっていたら完了です)

2) クライアント:Clerkトークンを渡してprivate channelにする
components/RealtimeBridge.tsxを、Clerkのsession tokenをSupabaseクライアントに渡す形に変更します。
ポイントは2つだけ:
-
accessToken(関数)でClerk tokenを返す -
channel("library", { config: { private: true } })にする
"use client";
import { useEffect, useMemo } from "react";
import { createClient } from "@supabase/supabase-js";
import { useSession } from "@clerk/nextjs";
type LoanUpdate = { copyId: string; status: "AVAILABLE" | "LOANED" | "LOST" | "REPAIR" };
export function RealtimeBridge({ onUpdate }:{ onUpdate:(p: LoanUpdate)=>void }) {
const { session } = useSession();
const supabase = useMemo(() => {
if (!session) return null;
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
// ClerkのセッショントークンをSupabaseへ渡す(Third-party auth)
accessToken: async () => session.getToken() ?? null,
}
);
}, [session]);
useEffect(() => {
if (!supabase) return;
const ch = supabase
.channel("library", { config: { private: true } })
.on("broadcast", { event: "loan:update" }, (msg: { payload: LoanUpdate }) => {
onUpdate(msg.payload);
})
.subscribe();
return () => {
supabase.removeChannel(ch);
};
}, [supabase, onUpdate]);
return null;
}
「private channel + RLS」で、接続時にrealtime.messagesのポリシー評価が走り、条件を満たさないクライアントは購読できません。
3) サーバー:送信側もprivate channelに寄せる
送信側(lib/realtime.ts)もprivate: trueを付けて、運用上のズレを減らします。
"use server";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false } }
);
type LoanUpdate = Record<string, unknown>;
export async function publishLoanEvent(payload: LoanUpdate) {
const ch = supabase.channel("library", { config: { private: true } });
await ch.send({ type: "broadcast", event: "loan:update", payload });
await supabase.removeChannel(ch);
}
4) Supabase:realtime.messagesにRLSを入れて「受信OK・送信NG(サーバーのみOK)」にする
SupabaseのSQL Editorで実行します。
狙いは:
- SELECT(受信):ログインユーザーだけ許可(topicは
libraryに限定) - INSERT(送信):service_roleだけ許可(topicは
libraryに限定)
-- 1) RLSを有効化
alter table realtime.messages enable row level security;
-- 2) 受信:authenticatedならlibrary topicのbroadcastを受け取れる
create policy "authenticated can receive library broadcasts"
on realtime.messages
for select
to authenticated
using (
(select realtime.topic()) = 'library'
and realtime.messages.extension in ('broadcast')
);
-- 3) 送信:service_roleだけがlibrary topicのbroadcastを送れる
create policy "service_role can send library broadcasts"
on realtime.messages
for insert
to service_role
with check (
(select realtime.topic()) = 'library'
and realtime.messages.extension in ('broadcast')
);
- ここでの考え方
- Realtime(Broadcast)は、メッセージ種類が
extension = 'broadcast'で判別できます - チャンネル名は
realtime.topic()で参照できます - なので「topic×extension×role」で素直に絞れるわけです
- Realtime(Broadcast)は、メッセージ種類が
5) 動作確認
確認観点は以下の3つです。
- 通常動作
- 2つのブラウザタブで
/booksを開く - 片方で貸出 → もう片方の在庫表示が即時に変わる
- 2つのブラウザタブで
- 未ログイン(or トークンが渡ってない)
-
private: trueにした後、ログインしてない状態だと購読できない
-
- クライアント送信の遮断
- ブラウザ側から
loan:updateを送ろうとしても失敗する - 代わりに、サーバー(
SUPABASE_SECRET_KEY)から送ったときだけ反映される
- ブラウザ側から
自分用メモ兼チェックリスト
-
private: trueにしただけではダメで、Supabase側のRealtime設定でpublic accessを許可していると意図が崩れる(private強制にならない) - Clerk tokenがSupabaseに渡っていないと、Supabase側は
anon扱いになり、to authenticatedのポリシーに弾かれて購読できない - topic名の揺れに注意
- クライアントとサーバで
libraryが一致しているか - 将来
library:classAのようにしたら、RLS側も合わせて更新すること
- クライアントとサーバで
まとめ
- 公開キーでRealtime購読する構成は普通にあり得るが、表示だけ変更する攻撃が入りやすい
- private channel +
realtime.messagesのRLSで「誰が受信できるか/誰が送信できるか」を制御する - 送信をservice_role(サーバー)だけに寄せると、UIの信頼性が上がる(運用が壊れにくくなる)
- あけましておめでとうございます🎍



