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?

Supabase で2段階認証(TOTP)を入れたら本番の書き込みが全部止まった:RLS 関数の security definer 漏れと、実装で踏んだ地雷5つ

0
Last updated at Posted at 2026-10-03

はじめに

個人開発の育児記録アプリに、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 の関連記事:

参考

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?