SMS 経由でワンタイム パスコード(OTP)を送信することは、電話番号の確認、アカウント復元、ステップアップ認証、支払い確認の一般的なフローです。ブラウザと SMS メッセージ アプリを切り替えて、複数桁のコードを記憶したり、コピーして貼り付けたりすると、ユーザーの負担が増え、離脱率が高くなります。また、ユーザーがなりすましドメインでフィッシング攻撃を受けやすくなります。
セマンティック <form> マークアップ、<input autocomplete="one-time-code" inputmode="numeric">、オリジンバウンド SMS 形式(@domain #code)、WebOTP API(OTPCredential)を組み合わせることで、レイヤ化されたプログレッシブ エンハンスメント型の検証フローを作成できます。
- 自動抽出と送信: WebOTP API をサポートするブラウザは、SMS の
@domain #code行とオリジンを照合し、<input>に入力して、1 回のタップで確認をトリガーします。 - オリジンにバインドされたキーボード自動入力:
autocomplete="one-time-code"をサポートするブラウザとオペレーティング システム(iOS、iPadOS、macOS の Safari など)は、@domain #codeメッセージを解析し、ドメインが一致する場合にのみ、キーボード自動入力バーにコードを提案します。 - スムーズな手動入力: 別のデバイスからコードを入力するユーザーは、
<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 つの構成を調整する必要があります。
SMS の最後の行で両方のオリジンをバインドする: 最初に最上位の埋め込みドメイン(
@shop.example)をリストし、その後に#<otp>、スペース、埋め込み iframe ドメイン(@bank.example)を続けます。Your verification code is 123456. @shop.example #123456 @bank.examplePermissions-PolicyHTTP レスポンス ヘッダーを送信する: 親ドキュメントの HTTP ヘッダーで、クロスオリジン iframe オリジンにotp-credentialsを委任します。Permissions-Policy: otp-credentials=(self "https://bank.example")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が使用できない場合:autocomplete="one-time-code"をサポートするブラウザ(iOS 14 以降、iPadOS、macOS の Safari と iCloud SMS リレーなど)は、@domain #codeSMS を解析し、<input>がフォーカスされたときにシステム キーボードの自動入力バーにコードを表示します。- 他のすべてのブラウザは、単一の
<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)はどちらも最新のブラウザでサポートされており、ポリフィルやフォールバック コードは必要ありません。