簡訊動態密碼表單驗證

透過簡訊傳送動態密碼 (OTP) 是常見的流程,可用於電話號碼驗證、帳戶救援、升級驗證和付款確認。在瀏覽器和簡訊應用程式之間切換,以記住或複製貼上多位數驗證碼,會造成使用者不便、提高訪客流失率,並讓使用者容易受到假冒網域的網路釣魚攻擊。

結合語意 <form> 標記、<input autocomplete="one-time-code" inputmode="numeric">、來源繫結簡訊格式 (@domain #code) 和 WebOTP API (OTPCredential),可建立分層、漸進增強的驗證流程:

  1. 自動擷取及提交:支援 WebOTP API 的瀏覽器會根據簡訊中的 @domain #code 行驗證來源、填入 <input>,並輕觸一下即可觸發驗證。
  2. 來源繫結鍵盤自動填入:支援 autocomplete="one-time-code" 的瀏覽器和作業系統 (例如 iOS、iPadOS 和 macOS 上的 Safari) 會剖析 @domain #code 訊息,並僅在網域相符時,在鍵盤自動填入列中建議驗證碼。
  3. 流暢的手動輸入:使用者從其他裝置輸入代碼時,會透過 inputmode="numeric" 收到數字虛擬鍵盤,不會遇到 <input type="number"> 的可用性問題。

安全性考量

簡訊動態密碼可驗證電話號碼擁有權,並提供便利的升級驗證,但簡訊傳輸和電話號碼可能會受到 SIM 卡交換、號碼回收和攔截的影響。請務必將用戶端自動填入功能和 WebOTP API 與來源繫結的 @domain #code 簡訊格式搭配使用,讓瀏覽器和 OS 先驗證目標網域是否相符,再建議或提供驗證碼。如要採用可防範網路釣魚的主要驗證機制,建議使用密碼金鑰,而非簡訊驗證碼 (請參閱密碼金鑰簡介)。

語意表單標記

使用 method="POST" 和明確的 <button type="submit">,將驗證控制項包裝在語意 <form> 元素中。請一律使用單一 <input> 元素,不要將數字拆分到多個方塊中。

  • 搭配使用 type="text" 和 inputmode="numeric":請勿使用 type="number" 傳送動態密碼。動態密碼是 ID 字串,開頭可能為零 (例如 012345),而非可計數的數量。使用 type="number" 會新增遞增/遞減微調按鈕,可去除開頭的零,並允許意外捲動滑鼠滾輪變更值。此外,也請避免使用 type="tel",這是用於完整電話號碼的舊版解決方法。將 type="text" 與 inputmode="numeric" 搭配使用,即可在行動裝置上顯示僅限數字的虛擬鍵盤。
  • 新增 autocomplete="one-time-code":指示瀏覽器和作業系統在 <input> 成為焦點時,在自動填入列中建議收到的簡訊驗證碼。
  • 將操作提示放在 <input> 上方:使用 for 和 id 建立可見的 <label> 關聯,並將任何格式操作說明 (透過 aria-describedby 連結) 放在 <input> 上方,這樣自動填入彈出式視窗和行動虛擬鍵盤就不會遮蓋操作說明。
  • 請勿將數字拆分到多個 <input> 元素:多方塊動態密碼模式 (六個獨立的 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;
}

來源繫結簡訊格式

如要讓 WebOTP API 和 Safari 的網域繫結 autocomplete="one-time-code" 自動填入功能驗證驗證碼是否屬於您的網站,後端簡訊服務必須設定簡訊最後一行的格式,其中來源網域的前置字元為 @,動態密碼的前置字元為 #:

Your verification code is 123456.

@example.com #123456

對送出的簡訊強制執行下列格式規則:

  • 先加入人類可讀的文字:簡訊開頭一律要加入清楚易懂、包含驗證碼的句子,方便使用者在其他裝置上手動輸入。
  • 將 @<domain> #<otp> 放在最後一行:@ 字元必須是最後一行的第一個字元,後面不得有任何行或簽名文字。
  • 僅使用代管網域:請勿在 @domain 權杖中加入網址配置 (https:// 或 http://)、通訊埠 (:8080) 或路徑 (/verify)。
  • 以單一空格分隔權杖:在 @<domain> 和 #<otp> 之間加入一個空格,且網域內或 # 與代碼之間不得包含空白字元。

將 WebOTP API 整合至驗證流程

WebOTP API (OTPCredential) 需要安全環境 (正式環境中的 https://,或本機開發期間的 http://localhost)。請勿貼上與 DOM 查詢和 form.submit() 呼叫無關的 DOMContentLoaded 指令碼,而是直接將 navigator.credentials.get() 整合至驗證 UI 的生命週期和提交處理常式:

  • 將要求生命週期與有效的一次性密碼步驟連結:在驗證輸入內容顯示並啟用時,開始監聽一次性密碼 (例如,在使用者要求簡訊驗證碼後,一次性密碼步驟掛接時,以及使用者每次點選「重新傳送驗證碼」按鈕時)。
  • 管理所有結束路徑的 AbortController:將 AbortSignal (signal: ac.signal) 傳遞至 navigator.credentials.get()。在下列情況下,請撥打 ac.abort():
    • 使用者手動提交表單 (或提交透過 autocomplete="one-time-code" 填入的驗證碼)。
    • 要求新的簡訊驗證碼 (「重新傳送驗證碼」),在建立新的控制器之前,先取消先前的控制器。
    • 驗證元件、對話方塊或路徑會卸載。
  • 觸發應用程式的提交處理常式 (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> 中接收簡訊動態密碼,這項功能主要用於嵌入式付款確認流程 (例如 3D Secure 驗證,其中 shop.example 嵌入來自 bank.example 的發卡機構驗證)。如要在跨來源 <iframe> 中支援 WebOTP,必須進行三項協調設定:

  1. 在簡訊最後一行繫結兩個來源:先列出頂層嵌入網域 (@shop.example),接著列出 #<otp>,然後列出空格和嵌入的 iframe 網域 (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. 傳送 Permissions-Policy HTTP 回應標頭:在父項文件的 HTTP 標頭中,將 otp-credentials 委派給跨來源 iframe 來源:

    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 polyfill,因為讀取傳入的簡訊需要作業系統層級的整合。請改用 <input> 標記內建的三層漸進增強功能:

  • 漸進增強體驗:在 if ('OTPCredential' in window) 後方保護 navigator.credentials.get({ otp: { transport: ['sms'] } }) 呼叫。如果無法使用 OTPCredential:
    1. 支援 autocomplete="one-time-code" 的瀏覽器 (例如 iOS 14+、iPadOS 和 macOS 上的 Safari,搭配 iCloud 簡訊轉送功能) 會剖析 @domain #code 簡訊,並在 <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),不需要 polyfill 或備用程式碼。