
auth0の POST /mfa/challenge を理解してみた(Auth0 MFAのみの再認証はできません)
はじめに
ユーザーが送金や個人情報の変更といった特に重要な操作をする際に、追加の本人確認を求める「ステップアップ認証」。これをAuth0で実現しようと考えたとき、mfa/challengeというAPIが目につくかもしれません。
「すでにログインしているユーザーに対して、このAPIを呼び出してMFAを要求すれば、簡単にステップアップ認証が実現できるのでは?」
私も最初はそう考え、Reactで実装を試みました。しかし、SPA (Single Page Application) のアーキテクチャでは根本的に不可能であることに気づきました。最終的にNext.jsのRegular Web AppでAPIを動作させることには成功しましたが、その過程でこのAPIがステップアップ認証のためには設計されていないことがわかりました。
本記事では、この技術的な調査の道のりを共有します。なぜAuth0のMFA Challenge APIがステップアップ認証に適していないのか、その仕組みと背景をコードと共に解説します。
検証環境
- macOS
- Cursor
- Next.js (Auth0サンプルアプリをベース)
なぜSPAではMFA Challenge APIを使えないのか?
結論から言うと、MFA Challengeのフローに必要な Password" Grant Typeは、セキュリティ上の理由からSPAで安全に利用できないからです。
Auth0のダッシュボードでSPAの設定を見ると、技術的には"Password" Grant Typeを有効にできます。しかし、有効にしようとすると、Auth0から次のような強い警告が表示されます。
Using Password or MFA grant types with public applications is not recommended.
(パブリックアプリケーションでPasswordまたはMFAグラントタイプを使用することは推奨されません。)
MFA Challenge APIを呼び出すには、まずユーザーのパスワードで認証する必要があります。そして、Auth0からmfa_requiredというエラーと共にmfa_tokenを取得します。Auth0の公式ドキュメントにもあるように、このmfa_tokenはまさに The token received from mfa_required error.(mfa_requiredエラーから受け取ったトークン)です。(Request MFA Challenge - Auth0 API Documentation)
このmfa_tokenを取得する一連のフローは「Resource Owner Password (ROP) Flow」と呼ばれます。

(画像はRegular Web Appの設定画面)
Auth0が推奨する標準的な認証フローは「Authorization Code Flow」です。公式ドキュメントでは This is the flow that regular web apps use to access an API. Use this endpoint to exchange an Authorization Code for a token.(これは、Regular Web AppがAPIにアクセスするために使用するフローです。このエンドポイントを使用して、Authorization Codeをトークンと交換します。)と説明されています。(Get Token - Auth0 API Documentation)
しかし、今回使用するResource Owner Password Flowはこれとは異なります。その理由はセキュリティにあります。
SPAはブラウザ上で動作するパブリッククライアントです。そのためclient_secretのような機密情報を安全に保持できません。一方、Resource Owner Password Flowを安全に利用するには、client_secretを秘匿できるサーバーサイドを持つ機密クライアント(Confidential Client)であることが前提です。
このセキュリティ原則により、SPAではResource Owner Password Flowを安全に実行できず、結果としてmfa/challenge APIを利用するべきではない、ということになります。
MFAチャレンジの全体フロー
まずは、Auth0 Regular Web Appの Settings -> Advanced SettingsにGrant Typesの PasswordとMFAを有効にする必要があります。

Regular Web Appでmfa/challenge APIを利用する場合、ユーザーは再度ユーザー名とパスワードを入力して「再認証」をします。全体の流れは以下の4ステップです。
- Server (/api/mfa/authenticate):- POST /oauth/token(- password-realmgrant) を呼び出し、- mfa_tokenを取得する。
- POST /mfa/challengeを呼び出し、OTP検証を準備する。
 
- User:
- 認証アプリ (Google Authenticatorなど) で6桁のOTPコードを取得する。
 
- Client (/mfa/verify/page.tsx):- OTPコードを入力し、/api/mfa/verifyに送信する。
 
- OTPコードを入力し、
- Server (/api/mfa/verify):- POST /oauth/token(- mfa-otpgrant) を呼び出し、OTPを検証して最終トークンを取得する。
 
このフローが示唆するように、このプロセスはログイン済みのセッションに対してMFAを追加するものではありません。MFAを強制する新しいログインフローそのものです。
中核となるコード
このフローを実現するためのサーバーサイドのコードを見てみましょう。Next.jsのAPI Routes機能を使って実装しています。
 1. 認証とMFAチャレンジ要求 (api/mfa/authenticate)
このエンドポイントは、ユーザーのID/パスワードを受け取り、Auth0に認証を試みます。MFAが必要な場合はmfa_tokenを取得し、チャレンジを要求します。
// ...前略
// Step 1: Resource Owner Password Flowで認証を試みる
// 'password-realm' grant typeを使い、MFAが必要な場合にmfa_requiredエラーを受け取る
const tokenResponse = await fetch(`https://${domain}/oauth/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'http://auth0.com/oauth/grant-type/password-realm', // password-realm grant
    client_id: clientId,
    client_secret: clientSecret,
    username: email,
    password: password,
    scope: 'openid profile email',
    realm: connection, // データベース接続名
    // MFAチェックを強制するためにMFA APIのaudienceを指定
    audience: `https://${domain}/mfa/`,
  }),
});
const tokenData = await tokenResponse.json();
// Step 2: MFAが要求されたかチェック
if (tokenData.error === 'mfa_required' && tokenData.mfa_token) {
  // Step 3: MFAチャレンジを要求
  const challengeResponse = await fetch(`https://${domain}/mfa/challenge`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      mfa_token: tokenData.mfa_token,
      challenge_type: 'otp', // OTPを要求
    }),
  });
  const challengeData = await challengeResponse.json();
  // mfa_tokenをHTTP-Onlyクッキーに保存してクライアントに返す
  const response = NextResponse.json({ 
    success: true, 
    mfa_token: tokenData.mfa_token,
    challenge: challengeData 
  });
  response.cookies.set('mfa_token', tokenData.mfa_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 600, // 10分
  });
  return response;
}
// ...後略
 2. OTPコードの検証 (api/mfa/verify)
ユーザーが入力したOTPコードを、クッキーに保存したmfa_tokenと共にAuth0に送信し、最終的なトークンを取得します。
// ...前略
// クッキーからmfa_tokenを取得
const mfaToken = request.cookies.get('mfa_token')?.value;
const { otp } = await request.json();
// Step 4: MFAコードを検証し、最終的なトークンを取得
const verifyResponse = await fetch(`https://${domain}/oauth/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'http://auth0.com/oauth/grant-type/mfa-otp', // mfa-otp grant
    client_id: clientId,
    client_secret: clientSecret,
    mfa_token: mfaToken,
    otp: otp, // ユーザーが入力したOTP
  }),
});
const tokenData = await verifyResponse.json();
if (verifyResponse.ok) {
  // 成功:クッキーを削除して、成功レスポンスを返す
  const response = NextResponse.json({ success: true, ...tokenData });
  response.cookies.delete('mfa_token');
  return response;
}
// ...後略
まとめ:MFA Challenge APIは「認証フローの一部」である
今回の検証から、Auth0のmfa/challenge APIは、すでに認証済みのセッションに対してMFAを追加するステップアップ認証の仕組みではないということがわかりました。
このAPIはあくまでログイン認証フローの一部です。利用するにはユーザー名とパスワードによる再認証が必須となります。たとえこのフローを完了しても、既存のログインセッションが置き換わるだけです。「重要な操作の直前にMFAを挟む」というステップアップ認証の要件は満たせません。
Auth0でステップアップ認証を実装したい場合は、Twilio Verifyのような外部サービスと連携するなど、別のアプローチを検討する必要があります。
MFA Challenge APIはその名前から誤解を招きやすいですが、その実態は認証フローに深く根差した機能です。目的とアーキテクチャを正しく理解し、適切な認証・認可フローを設計することが重要です。










