透過簡訊傳送動態密碼 (OTP) 是常見的流程,可用於電話號碼驗證、帳戶救援、升級驗證和付款確認。在瀏覽器和簡訊應用程式之間切換,以記住或複製貼上多位數驗證碼,會造成使用者不便、提高訪客流失率,並讓使用者容易受到假冒網域的網路釣魚攻擊。
結合語意 <form> 標記、<input autocomplete="one-time-code" inputmode="numeric">、來源繫結簡訊格式 (@domain #code) 和 WebOTP API (OTPCredential),可建立分層、漸進增強的驗證流程:
- 自動擷取及提交:支援 WebOTP API 的瀏覽器會根據簡訊中的
@domain #code行驗證來源、填入<input>,並輕觸一下即可觸發驗證。 - 來源繫結鍵盤自動填入:支援
autocomplete="one-time-code"的瀏覽器和作業系統 (例如 iOS、iPadOS 和 macOS 上的 Safari) 會剖析@domain #code訊息,並僅在網域相符時,在鍵盤自動填入列中建議驗證碼。 - 流暢的手動輸入:使用者從其他裝置輸入代碼時,會透過
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,必須進行三項協調設定:
在簡訊最後一行繫結兩個來源:先列出頂層嵌入網域 (
@shop.example),接著列出#<otp>,然後列出空格和嵌入的 iframe 網域 (@bank.example):Your verification code is 123456. @shop.example #123456 @bank.example傳送
Permissions-PolicyHTTP 回應標頭:在父項文件的 HTTP 標頭中,將otp-credentials委派給跨來源 iframe 來源: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 polyfill,因為讀取傳入的簡訊需要作業系統層級的整合。請改用 <input> 標記內建的三層漸進增強功能:
- 漸進增強體驗:在
if ('OTPCredential' in window)後方保護navigator.credentials.get({ otp: { transport: ['sms'] } })呼叫。如果無法使用OTPCredential:- 支援
autocomplete="one-time-code"的瀏覽器 (例如 iOS 14+、iPadOS 和 macOS 上的 Safari,搭配 iCloud 簡訊轉送功能) 會剖析@domain #code簡訊,並在<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),不需要 polyfill 或備用程式碼。