SMS による OTP フォームの確認

SMS 経由でワンタイム パスコード(OTP)を送信することは、電話番号の確認、アカウント復元、ステップアップ認証、支払い確認の一般的なフローです。ブラウザと SMS メッセージ アプリを切り替えて、複数桁のコードを記憶したり、コピーして貼り付けたりすると、ユーザーの負担が増え、離脱率が高くなります。また、ユーザーがなりすましドメインでフィッシング攻撃を受けやすくなります。

セマンティック <form> マークアップ、<input autocomplete="one-time-code" inputmode="numeric">、オリジンバウンド SMS 形式(@domain #code)、WebOTP API(OTPCredential)を組み合わせることで、レイヤ化されたプログレッシブ エンハンスメント型の検証フローを作成できます。

  1. 自動抽出と送信: WebOTP API をサポートするブラウザは、SMS の @domain #code 行とオリジンを照合し、<input> に入力して、1 回のタップで確認をトリガーします。
  2. オリジンにバインドされたキーボード自動入力: autocomplete="one-time-code" をサポートするブラウザとオペレーティング システム(iOS、iPadOS、macOS の Safari など)は、@domain #code メッセージを解析し、ドメインが一致する場合にのみ、キーボード自動入力バーにコードを提案します。
  3. スムーズな手動入力: 別のデバイスからコードを入力するユーザーは、<input type="number"> のユーザビリティ上の危険を伴うことなく、inputmode="numeric" を介して数字の仮想キーパッドを受け取ります。

セキュリティ上の考慮事項

SMS OTP は、電話番号の所有を確認し、便利なステップアップ認証を提供しますが、SMS 転送と電話番号は、SIM スワップ、番号の再利用、傍受の対象となる可能性があります。クライアントサイドの自動入力と WebOTP API は、常にオリジンにバインドされた @domain #code SMS 形式と組み合わせて使用してください。これにより、ブラウザと OS は、コードを提案または配信する前に、ターゲット ドメインが一致することを確認します。フィッシング耐性のあるプライマリ認証には、SMS ベースのコードよりもパスキーを優先します(パスキーの概要を参照)。

セマンティック フォーム マークアップ

確認コントロールを、method="POST" と明示的な <button type="submit"> を含むセマンティック <form> 要素でラップします。数字を複数のボックスに分割するのではなく、常に 1 つの <input> 要素を使用します。

  • type="text" を inputmode="numeric" と併用する: OTP コードに type="number" を使用しないでください。ワンタイム パスコードは、先頭にゼロを含む可能性がある識別子文字列(012345 など)であり、カウント可能な数量ではありません。type="number" を使用すると、増減スピナーボタンが追加され、先頭のゼロが削除される可能性があります。また、スクロール ホイールの値が誤って変更される可能性があります。また、type="tel" は完全な電話番号を対象とした以前の回避策であるため、使用しないでください。type="text" と inputmode="numeric" を組み合わせて、モバイル デバイスに数字のみの仮想キーボードを表示します。
  • autocomplete="one-time-code" を追加する: <input> がフォーカスされているときに、受信した SMS 確認コードを自動入力バーに表示するようブラウザとオペレーティング システムに指示します。
  • 説明のヒントを <input> の上に配置する: for と id を使用して、表示される <label> を関連付け、形式の説明(aria-describedby を介してリンク)を <input> の上に視覚的に配置します。これにより、自動入力のポップオーバーやモバイルの仮想キーボードが説明を覆うことがなくなります。
  • 複数の <input> 要素に数字を分割しない: マルチボックス OTP パターン(6 つの個別の maxlength="1" 入力)は、多くのブラウザでネイティブの autocomplete="one-time-code" 自動入力を妨げ、コピー&ペーストを阻害し、スクリーン リーダーのナビゲーションを複雑にします。セグメント化されたビジュアル デザインが必要な場合は、letter-spacing と等幅フォントを使用して単一の <input> のスタイルを設定します。
<form id="otp-form" action="/verify-otp" method="POST">
  <div class="form-group">
    <label for="otp-input">Verification code</label>
    <!-- Place the hint above the input so autofill popovers and mobile keyboards do not obscure it -->
    <p id="otp-format-hint" class="field-hint">
      Enter the 6-digit code sent to your phone.
    </p>
    <!-- MANDATORY: Use a single input with type="text", inputmode="numeric", and autocomplete="one-time-code" -->
    <input
      type="text"
      id="otp-input"
      name="otp"
      inputmode="numeric"
      autocomplete="one-time-code"
      pattern="\d{6}"
      maxlength="6"
      aria-describedby="otp-format-hint"
      required>
  </div>
  <button type="submit">Verify code</button>
</form>
/* Optional: Style a single input to visually space digits without splitting the DOM element */
#otp-input {
  font-family: ui-monospace, monospace;
  font-size: 1.25rem; /* Example value: must be at least 1rem (16px) to prevent mobile browser auto-zoom */
  letter-spacing: 0.35em;
  min-height: 48px;
  padding: 0.5rem 0.75rem;
  box-sizing: border-box;
}

オリジンバウンド SMS メッセージの形式

WebOTP API と Safari のドメインバインド autocomplete="one-time-code" 自動入力の両方で、コードがウェブサイトに属していることを確認するには、バックエンド SMS サービスで、テキスト メッセージの最後の行を、送信元ドメインの前に @、OTP の前に # を付けてフォーマットする必要があります。

Your verification code is 123456.

@example.com #123456

送信 SMS メッセージに次の形式ルールを適用します。

  • 最初に人が読めるテキストを含める: SMS の先頭には、コードを含む、人が読める明確な文を必ず配置します。これにより、別のデバイスで手動で入力するユーザーが簡単に読み取れるようになります。
  • @<domain> #<otp> を最後の行に配置する: @ 文字は、最後の行の最初の文字にする必要があります。その後に続く行や署名テキストは含めないでください。
  • 所有ドメインのみを使用する: @domain トークンに URL スキーム(https:// または http://)、ポート(:8080)、パス(/verify)を含めないでください。
  • トークンを 1 つのスペースで区切る: @<domain> と #<otp> の間に 1 つのスペースを挿入します。ドメイン内や # とコードの間にスペースを挿入しないでください。

WebOTP API を確認フローに統合する

WebOTP API(OTPCredential)には、セキュア コンテキスト(本番環境では https://、ローカル開発中は http://localhost)が必要です。DOM をクエリして form.submit() を呼び出す、接続されていない DOMContentLoaded スクリプトを貼り付ける代わりに、navigator.credentials.get() を確認 UI のライフサイクルと送信ハンドラに直接統合します。

  • リクエストのライフサイクルをアクティブな OTP ステップに関連付ける: 確認入力がレンダリングされてアクティブになったときに、OTP のリッスンを開始します(たとえば、ユーザーが SMS コードをリクエストした後に OTP ステップがマウントされたとき、およびユーザーが [コードを再送信] ボタンをクリックしたとき)。
  • すべての終了パスで AbortController を管理する: AbortSignal(signal: ac.signal)を navigator.credentials.get() に渡します。ac.abort() は、次の場合に呼び出します。
    • ユーザーがフォームを手動で送信する(または autocomplete="one-time-code" を介して入力されたコードを送信する)。
    • 新しい SMS コードがリクエストされ([コードを再送信])、新しいコントローラを作成する前に以前のコントローラが中止される。
    • 確認コンポーネント、ダイアログ、またはルートがマウント解除された。
  • アプリケーションの送信ハンドラ(form.requestSubmit())をトリガーする: navigator.credentials.get() が otp で解決されたら、otpInput.value = otp.code を設定して form.requestSubmit() を呼び出します(または、検証関数を直接呼び出します)。form.submit() は呼び出さないでください。form.submit() は submit イベント リスナー(event.preventDefault() / fetch() ハンドラ)をバイパスし、ネイティブ制約検証をスキップするためです。
const otpForm = document.getElementById('otp-form');
const otpInput = document.getElementById('otp-input');

let otpAbortController = null;

function stopSmsOtpListener() {
  if (otpAbortController) {
    otpAbortController.abort();
    otpAbortController = null;
  }
}

async function startSmsOtpListener() {
  // Feature-detect WebOTP support
  if (!otpInput || !('OTPCredential' in window)) {
    return;
  }

  // Cancel any previous pending WebOTP request (e.g., when re-sending an SMS code)
  stopSmsOtpListener();
  const ac = new AbortController();
  otpAbortController = ac;

  try {
    const otp = await navigator.credentials.get({
      otp: { transport: ['sms'] },
      signal: ac.signal,
    });

    if (!otp) return;
    otpInput.value = otp.code;

    // Trigger standard form submission so submit event listeners and validation run
    if (otpForm) {
      otpForm.requestSubmit();
    }
  } catch (err) {
    // Expected when aborted by manual submission, resend, unmount, or user dismissal
    if (err && err.name === 'AbortError') {
      return;
    }
    console.error('WebOTP verification error:', err);
  }
}

otpForm.addEventListener('submit', async (event) => {
  // MANDATORY: Abort any in-flight WebOTP request when the form is submitted
  stopSmsOtpListener();

  // Application-specific submission logic (e.g., AJAX fetch or native POST)
  event.preventDefault();
  const formData = new FormData(otpForm);
  // await fetch(otpForm.action, { method: 'POST', body: formData });
});

// Start listening when the OTP verification step becomes active
startSmsOtpListener();

クロスオリジン iframe の検証

クロスオリジン <iframe> 内での SMS OTP の受信はデフォルトで制限されており、主に埋め込み型の支払い確認フロー(shop.example が bank.example からの発行者チャレンジを埋め込む 3D セキュア認証など)で使用されます。クロスオリジン <iframe> 内で WebOTP をサポートするには、次の 3 つの構成を調整する必要があります。

  1. SMS の最後の行で両方のオリジンをバインドする: 最初に最上位の埋め込みドメイン(@shop.example)をリストし、その後に #<otp>、スペース、埋め込み iframe ドメイン(@bank.example)を続けます。

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Permissions-Policy HTTP レスポンス ヘッダーを送信する: 親ドキュメントの HTTP ヘッダーで、クロスオリジン iframe オリジンに otp-credentials を委任します。

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. allow="otp-credentials" を <iframe> 要素に追加する: 埋め込みページの <iframe> タグで権限ポリシーを明示的に付与し、スクリーン リーダー用にわかりやすい title 属性を含めます。

<iframe
  src="https://bank.example/verify"
  allow="otp-credentials"
  title="Payment verification">
</iframe>

https://bank.example によって提供される <iframe> ドキュメント内で、上記と同じ <form> マークアップと navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) 検証ライフサイクルを実行します。

代替戦略

WebOTP API のフォールバック

ターゲット ブラウザが WebOTP API(OTPCredential)をサポートしていない場合は、サードパーティの JavaScript ポリフィルを読み込まないでください。受信 SMS メッセージの読み取りには、オペレーティング システム レベルの統合が必要になるためです。代わりに、<input> マークアップに組み込まれている 3 段階のプログレッシブ エンハンスメントを利用します。

  • プログレッシブ エンハンスメント エクスペリエンス: if ('OTPCredential' in window) の背後にある navigator.credentials.get({ otp: { transport: ['sms'] } }) 呼び出しを保護します。OTPCredential が使用できない場合:
    1. autocomplete="one-time-code" をサポートするブラウザ(iOS 14 以降、iPadOS、macOS の Safari と iCloud SMS リレーなど)は、@domain #code SMS を解析し、<input> がフォーカスされたときにシステム キーボードの自動入力バーにコードを表示します。
    2. 他のすべてのブラウザは、単一の <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required> での手動入力にフォールバックし、モバイル デバイスにテンキーを表示して、<button type="submit"> を介して送信します。

権限ポリシーのフォールバック

ブラウザが Permissions-Policy と <iframe allow="otp-credentials"> を介した otp-credentials の委任をサポートしていない場合、クロスオリジン <iframe> 内の埋め込み検証 <form> は、未処理の例外をスローすることなく、autocomplete="one-time-code" キーボードの候補と手動の数値入力に適切にフォールバックします。

inputmode と AbortController のサポート

HTML の inputmode 属性と AbortController(AbortSignal)はどちらも最新のブラウザでサポートされており、ポリフィルやフォールバック コードは必要ありません。