背景
個人開発しているTRPG(卓上RPG)のGM向け管理ツール「CampaignDesk」では、GMが発行した招待リンク(/join/:campaignId)からプレイヤーがキャンペーンに参加します。
当初、このアプリは全ルートがログイン必須でした。つまり招待リンクを踏んだプレイヤーは、参加する前にまずアカウント登録(メール認証)を求められる構成になっていました。
これは明らかに離脱ポイントです。GMからDiscordなどでリンクを渡されたプレイヤーが、遊ぶ前にメール確認まで求められたら普通に面倒くさがって離脱します。かといって「参加だけなら誰でも自由に」とRLSを緩めるのはデータ設計上避けたい。この間を埋める方法として、Supabaseの Anonymous Sign-ins を採用しました。
なぜAnonymous Sign-insを選んだか
候補として以下を検討しました。
- メールリンク認証を必須のまま: 離脱率が高い(今回の課題そのもの)
- 招待トークンを自作してRLSをトークンベースに変更: 全テーブルのRLS設計を作り直す必要があり影響範囲が大きい
- Supabase Anonymous Sign-ins: Postgres側は「ロールがauthenticatedのユーザー」という点で通常ユーザーと同じ扱いになるため、既存のRLS/外部キー設計をほぼ変更せずに済む
signInAnonymously() で発行されるセッションは、auth.uid() が実在し、Postgresロールも authenticated になります。JWTに is_anonymous: true のクレームが乗るだけなので、既存の「auth.uid() ベースのRLS」「auth.users への外部キー」という設計をそのまま使い回せる、という点が決め手でした。
実装
middlewareでのルーティング制御
認証状態のチェックは src/lib/supabase/middleware.ts で行っています。ポイントは以下の3つです。
- /dashboard はGM専用なので、匿名ユーザーはアクセス不可(is_anonymous を見て弾く)
- /join, /play は未ログインなら自動的に匿名サインインしてそのまま通す
- 匿名サインインが失敗した場合は、これまで通り /login にフォールバック(既存ユーザー体験を壊さない)
const { data: { user } } = await supabase.auth.getUser();
const isRealUser = !!user && !user.is_anonymous;
const { pathname } = request.nextUrl;
const guestAllowedPrefixes = ["/join", "/play"];
if (!isRealUser && pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + request.nextUrl.search);
return NextResponse.redirect(url);
}
if (!user && guestAllowedPrefixes.some((prefix) => pathname.startsWith(prefix))) {
const { error } = await supabase.auth.signInAnonymously();
if (error) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + request.nextUrl.search);
return NextResponse.redirect(url);
}
return supabaseResponse;
}
「GM専用の/dashboard」と「ゲスト参加を許す/join・/play」を明確に分けているのがポイントです。これを分けないと、匿名の「ゲスト」が普通にダッシュボードへ入り込み、無料枠のキャンペーンを作成できてしまう、という意図しない抜け道ができてしまいます。
落とし穴になりそうだった点
参加処理を担う join_campaign() はこういうSQL関数です。
insert into campaign_players (campaign_id, user_id, player_name)
values (v_campaign_id, auth.uid(), p_player_name);
campaign_players.user_id は auth.users(id) への not null な外部キーです。もしAnonymous Sign-insが「本物のauth.usersレコードを作らない、別枠の仮ユーザー」のような実装だったら、ここでFK制約に引っかかってこの設計は破綻していました。
実際にはAnonymous Sign-insも auth.users に通常のユーザーと同じ形でレコードを作るため、SQL側は一切変更せずに済みました。ここは実装前にコードを追って確認しておいて正解でした(「middlewareだけ直せば動くだろう」で進めていたら、この場所で初めて気づいて手戻りになっていたと思います)。
セキュリティは大丈夫か
匿名認証を有効化すると「誰でも認証済みユーザーになれる」状態になるので、既存のRLSがそのまま安全かどうかは別途確認が必要でした。
確認した観点は以下の3点です。
- 全テーブルでRLSが有効になっているか — マイグレーション全体を確認し、RLS未設定のテーブルがないことを確認
- ポリシーがauth.uid()ベースで正しくスコープされているか — 例えば参加者向けのイベント取得は次のように campaign_players へのメンバーシップチェックが入っている
-- session_events の SELECT ポリシー例
exists (
select 1 from campaign_players cp
where cp.campaign_id = session_events.campaign_id
and cp.user_id = auth.uid()
)
- SECURITY DEFINER関数がRLSをバイパスする代わりに、内部で権限チェックをしているか — 例えば get_player_session_view() は関数内で自前のメンバーシップチェックを行い、非メンバーなら例外を投げる設計になっています
if not exists (
select 1 from campaign_players cp
where cp.campaign_id = p_campaign_id and cp.user_id = auth.uid()
) then
raise exception 'not a member of this campaign';
end if;
匿名セッションであっても auth.uid() は実在するUUIDなので、これらのチェックは通常ユーザーとまったく同じロジックで機能します。匿名認証を入れたからといって新しく空いた穴は無い、という結論になりました。
そのまま使うと危険です。実際に参加した匿名ユーザーは campaign_players に行を持ち、キャラクターシートやセッション履歴という実データを紐づけて持っているため、一律削除だとプレイヤーのデータを黙って消してしまいます。
そこで、campaign_players に一度も紐づかなかった匿名ユーザーだけを対象にする関数を pg_cron で毎日実行する形にしました。
create or replace function cleanup_orphaned_anonymous_users()
returns void
language sql
security definer
set search_path = public
as $$
delete from auth.users u
where u.is_anonymous is true
and u.created_at < now() - interval '2 days'
and not exists (
select 1 from campaign_players cp where cp.user_id = u.id
);
$$;
select cron.schedule(
'cleanup-orphaned-anonymous-users',
'0 18 * * *', -- 毎日 UTC18:00(JST 3:00)
$$ select cleanup_orphaned_anonymous_users(); $$
);
まとめ
- Supabase Anonymous Sign-insは、既存のRLS/外部キー設計を変えずに「ログイン不要のゲスト参加」を実現できる
- ただし「ゲストに開いていい範囲」と「本アカウント専用の範囲」はmiddlewareで明確に分離する必要がある
- 匿名認証を入れたあとのセキュリティ確認は「RLS有効化」「ポリシーのスコープ」「SECURITY DEFINER関数内チェック」の3点を洗い出せば十分
- 未参加のまま残る匿名ユーザーは、汎用的な「n日で一律削除」ではなく、実データを持つかどうかで対象を絞ったクリーンアップにするのが安全
実際に触ってみる
ここで書いた仕組みは、実際に動いているものをログイン不要で触れます。
- デモ(ログイン不要・データは保存されません): https://campaigndesk-ashy.vercel.app/demo
- アプリ本体: https://campaigndesk-ashy.vercel.app
記事内のコードとほぼ同じ挙動を、招待リンク経由の参加フローとして実際に体験できます。