はじめに
個人開発の育児記録アプリに、Supabase Auth の2段階認証(MFA / TOTP)を入れました。
実装は Claude Code に任せ、筆者は要件と確認を担当しました。
その結果、リリースした夜に本番の書き込みがすべて失敗する障害を起こしました。
MFA を登録していないユーザーも含めて、記録の追加も赤ちゃんの登録もできなくなりました。
さらに、障害を直したあとも、実機で使ってみるたびにバグが見つかりました。
この記事では、障害の振り返りと、Supabase の TOTP を実装するときに踏んだ地雷をまとめます。
Supabase で MFA を入れようとしている方や、RLS(行レベルセキュリティ)から auth スキーマを参照しようとしている方に向けた内容です。
TL;DR
- RLS から
auth.mfa_factorsを読む関数にsecurity definerを付け忘れ、authenticatedロールでpermission deniedになって全ユーザーの書き込みが失敗した - 事前検証は使い捨ての素の Postgres で行っていて、Supabase 本番の
authスキーマの権限制限が再現されていなかった -
mfa.enroll()のqr_codeはそのまま<img src>に入れられる data URL。自分でもう一度包むと壊れる -
friendlyNameを渡さないと、2本目の認証アプリの登録が必ず失敗する(mfa_factor_name_conflict) - ログイン時に
totp[0]固定で検証すると、2台目の認証アプリでログインできない。最終的に「登録済みの全部と照合する」方式にした
前提
| 項目 | 内容 |
|---|---|
| 構成 | Next.js(App Router)/ Supabase(認証・DB)/ Vercel |
| supabase-js | 2.116 系 |
| MFA の方式 | TOTP(認証アプリの6桁コード)。オプトイン(設定画面で登録した人だけ必須) |
| 守り方 | ①ログイン後にコード入力画面を挟む ②保護ページの入口で aal を確認 ③RLS で aal2 を強制 |
aal は「認証の保証レベル」です。パスワードだけでログインしたセッションは aal1、2段階目まで通ると aal2 になります。
障害:本番の書き込みがすべて失敗した
何をしたか
Supabase 公式の MFA ガイドには、「MFA を登録したユーザーにだけ aal2 を求める」RLS の例が載っています。
create policy "Policy name."
on table_name
as restrictive -- very important!
to authenticated
using (
array[(select auth.jwt()->>'aal')] <@ (
select
case
when count(id) > 0 then array['aal2']
else array['aal1', 'aal2']
end as aal
from auth.mfa_factors
where ((select auth.uid()) = user_id) and status = 'verified'
));
as restrictive のポリシーは、既存の permissive なポリシーと AND 条件で効きます。既存のポリシーを書き換えずに、条件を1つ足せるのが利点です。
これを6つのテーブルに付けるため、判定部分を関数に切り出しました。
create or replace function user_meets_required_aal()
returns boolean
language sql
stable
set search_path = public
as $$
select array[(select auth.jwt()->>'aal')] <@ (
select case when count(id) > 0 then array['aal2'] else array['aal1', 'aal2'] end
from auth.mfa_factors
where (select auth.uid()) = user_id and status = 'verified'
);
$$;
create policy "require_aal2_if_mfa_enrolled" on records
as restrictive to authenticated
using (user_meets_required_aal());
-- 同じポリシーを他の5テーブルにも付ける
何が起きたか
この migration を本番に適用したあと、記録の追加や赤ちゃんの登録といった書き込みが、すべて次のエラーで失敗しました。
permission denied for table mfa_factors
原因は、関数が呼び出したユーザーの権限で動いていたことです。
authenticated ロールには auth.mfa_factors を直接読む権限がありません。そのため、関数の中のサブクエリで弾かれました。
restrictive ポリシーは、そのテーブルへのクエリのたびに評価されます。
そのため、MFA を登録していないユーザーも含めて、全員が巻き込まれました。
どう直したか
関数に security definer を付けて、関数の所有者の権限で auth.mfa_factors を読むようにしました。
あわせて、security definer の関数の定石どおり search_path を空にしています。
create or replace function user_meets_required_aal()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select array[(select auth.jwt()->>'aal')] <@ (
select case when count(id) > 0 then array['aal2'] else array['aal1', 'aal2'] end
from auth.mfa_factors
where (select auth.uid()) = user_id and status = 'verified'
);
$$;
search_path = '' にすると、スキーマ名を省略したテーブル名は解決されなくなります。この関数は auth. を付けて書いているので、そのまま動きます。
security definer の関数は、呼び出したユーザーの RLS を飛び越えて動きます。中で「自分(auth.uid())の行だけを見る」条件を必ず付け、返す値も最小限(ここでは真偽値だけ)にしてください。
Supabase の Security Advisor では、この関数に security_definer_function_executable の警告が出ます。筆者は「中で本人の行だけに絞っていて、真偽値しか返さない」ことを確認したうえで、許容する警告として記録しています。
公式ガイドの例は、関数に切り出さずポリシーに直接サブクエリを書く形です。筆者はこの書き方のまま本番で動くかどうかは試していません。関数に切り出す場合は、security definer が要ることに注意してください。
経過
| 時刻 | 出来事 |
|---|---|
| 21:51 | MFA の実装をコミット |
| 22:04 |
/code-review の指摘で、対象テーブルに漏れていた1つを追加 |
| 22時すぎ | AI が Supabase の MCP 経由で migration を本番に適用(ここから障害) |
| 22:24 以降 | 別の変更を push。CI の E2E(本番 Supabase 相手)が赤ちゃん登録で失敗し、発覚 |
| 22:55 | 修正の migration を適用し、E2E 36件の全緑を確認してコミット |
本番に適用してから直すまで、1時間足らずでした。個人利用のアプリだったので、実害は小さく済みました。
なぜ事前の検証で気づけなかったか
実装時、AI は使い捨ての Postgres コンテナで RLS を検証していました。
- MFA 未登録なら常に許可
- MFA 登録済みで aal1 なら拒否
- MFA 登録済みで aal2 なら許可
この3ケースを SELECT・INSERT の両方で確かめ、すべて成功していました。
ところが、このコンテナには Supabase 本番が auth スキーマにかけている権限制限が再現されていませんでした。
素の Postgres では、テスト用に作った auth.mfa_factors が普通に読めてしまいます。だから「動いて見えた」のです。
気づけたのは、本番の Supabase を相手に走る E2E テストがあったからです。
画面に permission denied for table mfa_factors が表示され、テストが失敗しました。
再発防止
-
authスキーマを参照する RLS 関数は、本番相当の権限がある環境(Supabase CLI のローカル環境か本番そのもの)で確かめる - DB の正本(
schema.sql)の関数定義に、「security definer必須」の理由をコメントで残す
実は、修正を migration には入れたものの、正本の schema.sql への反映を翌日まで忘れていました。別の作業で schema.sql と本番の関数定義を照合したときに気づいています。
直したら、migration と正本の両方を更新するところまでを1セットにしています。
実装で踏んだ地雷
障害を直したあと、実際にスマホで MFA を登録しようとして、次々にバグが見つかりました。
どれも AI の実装と自動テストでは見つからず、筆者が実機で触って初めて分かったものです。
地雷1:QR コードが表示されない(data URL の二重包み)
設定画面の QR コードが、壊れた画像アイコンになっていました。
const { data } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
// qr_code を「生の SVG 文字列」だと思って、もう一度 data URL に包んでいた
const src = `data:image/svg+xml;utf-8,${encodeURIComponent(data.totp.qr_code)}`
data.totp.qr_code は、最初から data:image/svg+xml;utf-8, で始まる data URL です。
これをさらに包んだので、二重の壊れた URL になっていました。
// そのまま <img src> に入れる
<img src={data.totp.qr_code} alt="TOTP登録用QRコード" />
公式ガイドの説明文には「SVG 形式の QR コードを返すので、data URL にエンコードして <img> で表示する」とあります。
一方で、ガイドのサンプルコードは data.totp.qr_code をそのまま src に入れています。
説明文だけを読むと「自分でエンコードする」と読めてしまうので、注意が必要です。
地雷2:スマホだけで使うアプリなのに、QR コードを読めない
直したあと、筆者が気づきました。
このアプリはスマホのホーム画面に追加して使う PWA です。表示している QR コードを、同じスマホのカメラでは読めません。
enroll() の戻り値には、QR コードと同じ内容の otpauth:// 形式の URI(data.totp.uri)も入っています。
これをリンクにして、タップするとそのまま認証アプリが開くボタンを、主な導線にしました。
<a href={data.totp.uri}>認証アプリで開いて登録する</a>
QR コードとシークレットの手入力は、「PC など別の端末で設定する場合」の手段として残しています。
地雷3:2本目の認証アプリが必ず登録できない
「スマホをなくしたときのために、2つ目も登録しておく」という案内を出していました。
ところが、実際に2つ目を登録しようとすると、必ず「登録を開始できませんでした」になりました。
本番 Supabase を相手に再現するスクリプトを書いて調べると、次のエラーでした。
mfa_factor_name_conflict
A factor with the friendly name "" for this user already exists
enroll() に friendlyName を渡していなかったため、名前が空文字になっていました。
同じユーザーの中で friendly name は重複できないので、2つ目は必ず失敗します。
await supabase.auth.mfa.enroll({
factorType: 'totp',
// 「ユーザーが付けた呼び名::一意にするための値」の形で保存し、表示は呼び名だけにする
friendlyName: `${label.trim()}::${Date.now()}`,
})
1本目しか登録しないテストでは、絶対に見つからないバグです。
地雷4:2台目の認証アプリではログインできない
2本目を登録できるようになったあと、筆者が「ログインのときは、どちらのアプリのコードを入れればいいの?」と聞いたところで、次のバグが見つかりました。
const { data } = await supabase.auth.mfa.listFactors()
const factorId = data.totp[0].id // いつも1つ目
await supabase.auth.mfa.challengeAndVerify({ factorId, code })
検証に使う factor がいつも1つ目に固定されていたので、2台目のアプリのコードは必ず弾かれていました。
「なくしたときのために2つ目を登録しておく」という案内が、実際には役に立たない状態でした。
最初は「どの認証アプリを使うか選ぶボタン」を付けて直しました。
しかし、筆者から「そんな画面のサービスは見たことがない」と指摘し、入力されたコードを、登録済みの全部と順に照合する方式に変えました。
let succeeded = false
for (const factorId of factorIds) {
const { error } = await supabase.auth.mfa.challengeAndVerify({ factorId, code })
if (!error) {
succeeded = true
break
}
}
これで、ユーザーはどの端末で登録したかを気にせず、いつも同じ入力欄にコードを入れるだけで済みます。
全部と照合する方式では、1回の入力で当たりになりうるコードが、登録している数だけ増えます。登録できる数に上限を設けるなど、アプリに合わせて検討してください。
地雷5:「認証アプリを持っていない人もいる」問題
筆者は「認証アプリを持っていない人もいるので、顔認証や指紋認証、メールでの2段階認証はどうか」と提案しました。AI に調べてもらった結果は次のとおりです。
| 案 | 判断 |
|---|---|
| 顔・指紋認証(パスキー) | 今の aal2 前提の RLS に組み込むのが難しく、見送り |
| メールでの2段階認証 | Supabase の MFA の方式にない。自作すると RLS の保護から外れるうえ、パスワード再設定と同じ経路なので2つ目の要素として弱い。見送り |
| 案内を工夫する | 採用 |
実は、iPhone の標準の「パスワード」App は、Web サイトの2段階認証の確認コードを設定・保存できます(Apple の公式サポートに手順があります)。
専用の認証アプリを入れなくても使えることを、登録画面の案内に書きました。
まとめ
- RLS から
authスキーマを読む関数は、security definer+search_path = ''にする。付け忘れると全員の書き込みが止まる - 素の Postgres での検証は、Supabase 本番の権限制限を再現しない。本番相当の環境か、本番相手の E2E で確かめる
-
qr_codeはそのまま<img src>へ。スマホで完結するアプリならotpauth://の URI をリンクにする -
friendlyNameは必ずユニークな値を渡す。2本目の登録と2台目でのログインは、テストに入れておく - AI の実装と自動テストを通っても、実機で触ると見つかるバグが残る。特に「2つ目」「別の端末」の操作は、自分で試す
Supabase の関連記事:
- Supabase の自動 GRANT が 2026-10-30 に廃止:RLS を書いたのに
42501になる理由と、migration に GRANT を書く型 - Supabase の select は1000件で黙って切れる:取りこぼしに気づいた経緯と全件取得関数の作り方
- Vercel の自動デプロイを止めて、GitHub Actions が全部緑のときだけ本番に出す