SMS OTP form verification
Delivering a one-time passcode (OTP) via SMS is a common flow for phone number verification, account recovery, step-up authentication, and payment confirmation. Switching between a browser and an SMS messaging app to memorize or copy-paste a multi-digit code introduces user friction, increases drop-off rates, and leaves users vulnerable to phishing on spoofed domains.
Combining semantic <form> markup, <input autocomplete="one-time-code" inputmode="numeric">, origin-bound SMS formatting (@domain #code), and the WebOTP API (OTPCredential) creates a layered, progressively enhanced verification flow:
- Automatic extraction and submission: Browsers supporting the WebOTP API verify the origin against the
@domain #codeline in the SMS, populate the<input>, and trigger verification with a single tap. - Origin-bound keyboard autofill: Browsers and operating systems that support
autocomplete="one-time-code"(such as Safari on iOS, iPadOS, and macOS) parse the@domain #codemessage and suggest the code in the keyboard autofill bar only when the domain matches. - Frictionless manual entry: Users entering a code from another device receive a numeric virtual keypad via
inputmode="numeric"without the usability hazards of<input type="number">.
Security considerations
Section titled “Security considerations”SMS OTP verifies possession of a phone number and provides convenient step-up verification, but SMS transport and phone numbers can be subject to SIM swapping, number recycling, and interception. Always pair client-side autofill and the WebOTP API with the origin-bound @domain #code SMS format so the browser and OS verify that the target domain matches before suggesting or delivering the code. For primary phishing-resistant authentication, prefer passkeys over SMS-based codes (see passkeys (guide not yet published on this site)).
Semantic form markup
Section titled “Semantic form markup”Wrap the verification control in a semantic <form> element with method="POST" and an explicit <button type="submit">. Always use a single <input> element rather than splitting digits across multiple boxes.
- Use
type="text"withinputmode="numeric": Never usetype="number"for OTP codes. A one-time passcode is an identifier string that may contain leading zeros (such as012345), not a countable quantity. Usingtype="number"adds increment/decrement spinner buttons, can strip leading zeros, and allows accidental scroll-wheel value changes. Also avoidtype="tel", which is a legacy workaround intended for full telephone numbers. Pairtype="text"withinputmode="numeric"to present a digits-only virtual keyboard on mobile devices. - Add
autocomplete="one-time-code": Instructs browsers and operating systems to suggest incoming SMS verification codes in the autofill bar when the<input>is focused. - Place instructional hints above the
<input>: Associate a visible<label>usingforandid, and place any format instructions (linked viaaria-describedby) visually above the<input>so autofill popovers and mobile virtual keyboards never cover the instructions. - Do not split digits across multiple
<input>elements: Multi-box OTP patterns (six separatemaxlength="1"inputs) break nativeautocomplete="one-time-code"autofill in many browsers, hinder copy-pasting, and complicate screen reader navigation. If a segmented visual design is required, style a single<input>withletter-spacingand a monospace font.
<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;}Origin-bound SMS message format
Section titled “Origin-bound SMS message format”For both the WebOTP API and Safari’s domain-bound autocomplete="one-time-code" autofill to verify that the code belongs to your website, your backend SMS service must format the last line of the text message with the origin domain prefixed by @ and the OTP prefixed by #:
Your verification code is 123456.
@example.com #123456Enforce the following formatting rules on the outgoing SMS message:
- Include human-readable text first: Always start the SMS with a clear human-readable sentence containing the code so users typing it manually on another device can read it easily.
- Place
@<domain> #<otp>on the very last line: The@character must be the first character of the final line, with no trailing lines or signature text after it. - Use only the host domain: Do not include a URL scheme (
https://orhttp://), port (:8080), or path (/verify) in the@domaintoken. - Separate tokens with a single space: Place exactly one space between
@<domain>and#<otp>, and never include whitespace inside the domain or between#and the code.
Integrating the WebOTP API into your verification flow
Section titled “Integrating the WebOTP API into your verification flow”The WebOTP API (OTPCredential) requires a Secure Context (https:// in production, or http://localhost during local development). Instead of pasting a disconnected DOMContentLoaded script that queries the DOM and calls form.submit(), integrate navigator.credentials.get() directly into the lifecycle and submission handler of your verification UI:
- Tie the request lifecycle to the active OTP step: Start listening for the OTP when the verification input is rendered and active (for example, when the OTP step mounts after the user requests an SMS code, and again whenever the user clicks a “Resend code” button).
- Manage an
AbortControlleracross all exit paths: Pass anAbortSignal(signal: ac.signal) tonavigator.credentials.get(). Callac.abort()whenever:- The user manually submits the form (or submits a code filled via
autocomplete="one-time-code"). - A new SMS code is requested (“Resend code”), aborting the previous controller before creating a new one.
- The verification component, dialog, or route unmounts.
- The user manually submits the form (or submits a code filled via
- Trigger the application’s submission handler (
form.requestSubmit()): Whennavigator.credentials.get()resolves withotp, setotpInput.value = otp.codeand callform.requestSubmit()(or invoke your verification function directly). Do NOT callform.submit(), becauseform.submit()bypassessubmitevent listeners (event.preventDefault()/fetch()handlers) and skips native constraint validation.
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 activestartSmsOtpListener();Cross-origin iframe verification
Section titled “Cross-origin iframe verification”Receiving an SMS OTP inside a cross-origin <iframe> is restricted by default and is primarily used for embedded payment confirmation flows (such as 3D Secure verification where shop.example embeds an issuer challenge from bank.example). Supporting WebOTP inside a cross-origin <iframe> requires three coordinated configurations:
- Bind both origins on the last line of the SMS: List the top-level embedding domain first (
@shop.example), followed by#<otp>, followed by a space and the embedded iframe domain (@bank.example):
Your verification code is 123456.
@shop.example #123456 @bank.example- Send the
Permissions-PolicyHTTP response header: Delegateotp-credentialsto the cross-origin iframe origin in the parent document’s HTTP headers:
Permissions-Policy: otp-credentials=(self "https://bank.example")- Add
allow="otp-credentials"to the<iframe>element: Explicitly grant the permission policy on the<iframe>tag in the embedding page and include a descriptivetitleattribute for screen readers:
<iframe src="https://bank.example/verify" allow="otp-credentials" title="Payment verification"></iframe>Inside the <iframe> document served by https://bank.example, run the same <form> markup and navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) verification lifecycle shown above.
Fallback strategies
Section titled “Fallback strategies”WebOTP API fallback
Section titled “WebOTP API fallback”Baseline: web-otp
If your target browsers do not support the WebOTP API (OTPCredential), do not load a third-party JavaScript polyfill, as reading incoming SMS messages requires operating-system-level integration. Instead, rely on the three-tier progressive enhancement built into the <input> markup:
- Progressive enhancement experience: Guard the
navigator.credentials.get({ otp: { transport: ['sms'] } })call behindif ('OTPCredential' in window). WhenOTPCredentialis unavailable:- Browsers that support
autocomplete="one-time-code"(such as Safari on iOS 14+, iPadOS, and macOS with iCloud SMS relay) parse the@domain #codeSMS and present the code in the system keyboard autofill bar when the<input>is focused. - All other browsers fall back to manual entry in the single
<input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required>, displaying a numeric keypad on mobile devices and submitting via the<button type="submit">.
- Browsers that support
Permissions Policy fallback
Section titled “Permissions Policy fallback”Baseline: permissions-policy
If the browser does not support delegating otp-credentials via Permissions-Policy and <iframe allow="otp-credentials">, the embedded verification <form> inside the cross-origin <iframe> degrades gracefully to autocomplete="one-time-code" keyboard suggestions and manual numeric input without throwing unhandled exceptions.
Inputmode and AbortController support
Section titled “Inputmode and AbortController support”Baseline: inputmode
Baseline: aborting
Both the HTML inputmode attribute and AbortController (AbortSignal) are supported across modern browsers and require no polyfill or fallback code.
Compatibility notes: Badges like Baseline: … mark web features referenced by this guide. Baseline status describes availability across the core browser set — it does not mean every browser or older version supports the feature. Feature data as published upstream, synced 2026-11-12.
This demo needs browser permissions or external resources, so it is not embedded. Open the demo as a standalone page (it runs in your browser; some features may require a supported browser and your permission).
Source: GoogleChrome/modern-web-guidance-src (Apache-2.0), upstream commit 179dbc6, synced 2026-11-12. Local changes: macros expanded to links/badges, first heading de-duplicated, demo and this footer added. This is an independent, unofficial site — not a Google publication.