TOTP登録直後、session cookieは第2要素未達のまま残る
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
TOTPのenrollが成功しても、ブラウザに残っているsession cookieは登録前に発行されたIDトークン由来のままで、第2要素を通過したことを示すsignInSecondFactor claimを持たない。登録直後にgetIdTokenResult(true)で強制リフレッシュしてこのclaimの有無を確認しない限り、強制対象ロールのユーザーは登録したその場で保護機能に入れない。
結論
Firebase AuthenticationでmultiFactor(user).enroll()によるTOTP登録を完了させても、ブラウザのsession cookieは更新されない。cookieは登録前、第2要素を通していないIDトークンから発行されたままなので、強制対象ロールに対する「第2要素を通過したか」のサーバー側チェックは登録直後も「未達」と判定し続ける。user.getIdTokenResult(true)で強制リフレッシュし、戻り値のsignInSecondFactorclaimの有無を見て、無ければそのまま使わせず再ログインへ誘導する必要がある。
症状
Identity Platform(Firebase Authentication)でTOTP(authenticatorアプリ)による第2要素を、特定ロールに事後的に必須化する場合を考える。対象ユーザーは今までパスワードだけでログインできていたので、まずログイン済みの状態で「account/mfa」のような画面からTOTPを登録(enroll)する。
登録フロー自体はIdP的には成功する。multiFactor(user).enroll(assertion, "Authenticator アプリ")はresolveし、multiFactor(user).enrolledFactorsを読み直すと登録済みの1件が返ってくる。ところが、登録が終わった直後にそのまま保護されたページや操作に進もうとすると、サーバー側は「第2要素未達」としてブロックし続ける。ブラウザもログイン画面に戻されたわけではなく、ユーザーからは「登録はできたのに何も変わらない」という挙動に見える。
原因
強制チェックの単一ソースは、session cookieに埋め込まれたIDトークンのclaimである。IdP自身の設計がそうなっている。firebase.sign_in_second_factorというclaimは、ログイン時に第2要素の検証(MultiFactorResolver.resolveSignIn)を経て発行されたIDトークンにだけ乗る。
MfaEnrollmentコンポーネントのenroll処理を見ると、これが素直に実装されている。
// sha 99ffbf95 時点、apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
try {
const assertion = TotpMultiFactorGenerator.assertionForEnrollment(secret, code.trim());
await multiFactor(user).enroll(assertion, "Authenticator アプリ");
} catch {
setError(
"コードが正しくないか、登録に失敗しました。authenticator のコードを確認してください。",
);
return;
}
ここでuserは、enroll呼び出しより前にクライアントSDKのcurrentUserから取ってきたオブジェクトであり、その中のIDトークンはパスワードだけでログインした時点のキャッシュを指している。enroll自体はIdP側に第2要素を追加するAPI呼び出しであって、ブラウザが保持しているIDトークンやそこから発行済みのsession cookieを書き換える効果は無い。
つまり「IdP上の登録状態」と「ブラウザが持っているトークン/cookieの状態」はこの時点で分離している。サーバー側のロール別強制チェックがcookieのclaimだけを見る設計(ADR-031の追補が明記する「強制の単一ソースはIdP=firebase.sign_in_second_factorclaim」)である以上、登録が成功していてもcookieを取り直さない限り「未達」のままになる。
直す
enrollが成功した直後に、第2要素を通過済みのIDトークンでsession cookieを明示的に取り直す処理を挟む。
// sha 99ffbf95 時点、apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
async function refreshSessionWithSecondFactor(user: User): Promise<boolean> {
try {
const result = await user.getIdTokenResult(true);
if (!result.signInSecondFactor) {
return false;
}
const res = await fetch("/api/auth/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idToken: result.token }),
});
return res.ok;
} catch {
return false;
}
}
ポイントはgetIdTokenResult(true)の第1引数trueで、これがSDKのキャッシュを無視してIdPへ新しいIDトークンを問い合わせる強制リフレッシュになる。enroll済みの状態でこれを呼ぶと、新しいトークンにはsignInSecondFactorclaimが乗っている。これをサーバーの/api/auth/sessionへ渡せば、session cookieを第2要素通過済みのものに入れ替えられる。
enroll確定時のハンドラ側は、この関数の戻り値で分岐する。
// sha 99ffbf95 時点、apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
// 強制対象 (MFA 未達) なら、第2要素付きトークンで cookie を取り直してそのまま使えるようにする。
// 取り直せない場合は、認証コード付きでログインし直してもらう (下の再ログインボタン)。
if (mfaPending && (await refreshSessionWithSecondFactor(user))) {
window.location.assign("/");
return;
}
router.refresh();
ここで一つ注意点がある。refreshSessionWithSecondFactorがfalseを返すケース(たとえばsignInSecondFactorclaimが期待通り乗らない、通信エラーで/api/auth/sessionが失敗する、など)が実際にありうる。このとき黙ってrouter.refresh()だけで終えると、ユーザーは「登録済みなのに入れない」という最初の症状に戻ってしまう。実装はここで諦めず、取り直しに失敗した場合専用の案内を出す。
// sha 99ffbf95 時点、apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
{mfaPending && enrolled.length > 0 ? (
<section style={warningCardStyle}>
<p style={warningTextStyle}>
登録済みです。続けて使うには、一度ログアウトして認証コード付きでログインし直してください。
</p>
<button type="button" onClick={signOutForMfaLogin} style={primaryBtnStyle}>
ログインし直す
</button>
</section>
) : null}
signOutForMfaLoginはclient/serverの両方のセッションを破棄してログイン画面に送る。
// sha 99ffbf95 時点、apps/web/app/app/account/mfa/_components/MfaEnrollment.tsx
async function signOutForMfaLogin(): Promise<void> {
await signOut(getClientAuth()).catch(() => undefined);
await fetch("/api/auth/signout", { method: "POST" }).catch(() => undefined);
window.location.assign("/login?next=/");
}
再ログイン時は通常のパスワード入力がauth/multi-factor-auth-requiredエラーを返し、そこでTOTPコード入力に切り替わって初めてMultiFactorResolver.resolveSignInを経たIDトークンが手に入る。
// sha 99ffbf95 時点、apps/web/app/login/_components/LoginForm.tsx
const credential = await signInWithEmailAndPassword(getClientAuth(), email, staffPassword);
await establishSession(await credential.user.getIdToken());
} catch (e) {
if ((e as { code?: unknown }).code === "auth/multi-factor-auth-required") {
// パスワードは正しい。第2要素の入力へ切り替える(ADR-031)。
setMfaResolver(getMultiFactorResolver(getClientAuth(), e as MultiFactorError));
「登録直後にその場で取り直す」と「取り直せなければ素直に再ログインさせる」の二段構えにすることで、session cookieが第2要素未達のまま残り続けるケースを塞いでいる。
再発防止
ADR-031の追補は、この強制チェックの判定根拠を「IdP上の登録件数」から「session cookieのclaim」に切り替えた経緯も記録している。
判定は登録件数ではなくセッション:旧ゲート(layoutでIdP登録件数を見て誘導)を廃し、session cookieのclaimから
AuthUser.mfaVerifiedを立て、requireRole(ページ・Server Action)/withSession(403)/getCurrentUser(Route Handler、401)の全入口で拒否する。layoutだけではServer Action/Route Handlerが素通りする穴があった(2026-10監査)。
この設計変更自体は「判定の抜け漏れを塞ぐ」という別の改善だが、判定根拠をcookie/claim一本に寄せたことで、今回のような「IdP側の登録とcookieの不一致」がそのまま症状として表面化しやすくなった側面もある。登録直後の取り直し処理は、この設計変更と対になる形で同じ変更に含まれている。
このコミットの差分自体には、取り直し処理そのものをテストする回帰テストは含まれていない。コミットメッセージには「Auth emulatorはTOTP非対応のためunitテストで検証」という記載があり、TOTP関連の検証はFirebase Auth emulatorの制約下でのunitテストに留まっている。getIdTokenResult(true)が返すclaimの値に依存する分岐(refreshSessionWithSecondFactorの成否)そのものを統合的に確認する自動テストは、今回の差分には見当たらない。同種の「登録直後にcookieを取り直す」実装を他の強制ロールに広げる際は、この分岐の自動テストが無いことを前提に、手動確認の手順を先に決めておく必要がある。
よくある質問
Q1TOTPをenrollしたのに保護機能に入れないのはなぜ?
multiFactor(user).enroll()はIdP側に第2要素を登録するだけで、ブラウザがすでに持っているsession cookieを更新しない。そのcookieは登録前の、第2要素を通していないIDトークンから発行されたものなので、強制対象ロールのサーバー側チェックは『第2要素未達』と判定し続ける。
Q2なぜクライアントSDKのcurrentUserを見るだけでは直らない?
currentUserのIDトークンはキャッシュされており、enroll直後もsignInSecondFactor claimを持たない古いトークンを指している。user.getIdTokenResult(true)で強制リフレッシュしてIdPから新しいトークンを取り直すまで、claimは更新されない。
Q3強制リフレッシュしてもclaimが無い場合はどうする?
そのままでは直せないと判断し、client/server両方のセッションをサインアウトしてから、TOTPコード入力を伴う通常ログインへ誘導する。再ログインのsignInWithEmailAndPasswordがauth/multi-factor-auth-requiredを返し、そこで初めて第2要素の検証を経たIDトークンが手に入る。
Q4この問題はどんな条件で起きる?
対象ロールに対してTOTPを事後的に必須化し、既存ログイン済みユーザーがその場でenrollを完了させる運用の場合に起きる。最初からTOTP必須でログインする新規セッションでは、ログイン時点でauth/multi-factor-auth-requiredを経由するため発生しない。
確認した環境
- firebase(client SDK) ^12.14.0 / Next.js ^16.3.7 / React ^19.0.0
- 2026-10-01 のADR追補(system_admin 先行必須化)に合わせて実装
この記事の根拠
- TypeScriptファイル 56〜141行目コミット 99ffbf9
- TypeScriptファイル 49〜77行目コミット 99ffbf9
- TypeScriptファイル 159〜168行目コミット 99ffbf9
- TypeScriptファイル 228〜239行目コミット 99ffbf9
- Markdownファイル 52〜57行目コミット ad3a6b6
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。