0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Clerk認証のNext.jsアプリでSupabase Realtime(Broadcast)をRLSで守ってみた

Posted at

この記事は、自分の制作物 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 channelconfig: { private: true }
    • 認証:ClerkセッショントークンをSupabaseに渡してauthenticated扱いにする(Third-party auth)
    • 送信:service_role(サーバー)だけ許可にして、クライアント送信を遮断

何が問題だったのか

  • 以前のコード

    • クライアント(購読側)
      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 integrationを有効化して、Clerk domainを取得
    2026-01-03 21_58_40-.png
    2026-01-03 21_58_57-.png

  • Supabase側でThird-party auth providerにClerkを追加(domainを貼る)
    2026-01-03 19_29_25-.png
    2026-01-03 19_30_20-.png

これで「Clerkのセッショントークンを、Supabaseの認可(RLS)に使う」準備ができました。(以下の画像のようにENABLEDとなっていたら完了です)
2026-01-03 19_30_34-.png

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」で素直に絞れるわけです

5) 動作確認

確認観点は以下の3つです。

  • 通常動作
    • 2つのブラウザタブで/booksを開く
    • 片方で貸出 → もう片方の在庫表示が即時に変わる
  • 未ログイン(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の信頼性が上がる(運用が壊れにくくなる)
  • あけましておめでとうございます🎍
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?