InstagramPhone number and OTP login

100%

Phone number and OTP login.

A returning user picks their country, types a phone number and enters the 6-digit code texted to them, or lets the browser fill it in. They land signed in on the page they wanted, without a password.

Intermediate36 minUpdated 2 Oct 2026

Builds on Email and password login.

Requirements exploration.

This design covers the web phone login of a photo-sharing product, the sibling of the email and password page. Sending the SMS, generating and checking codes, fraud scoring and the carriers are a server black box. The design is about what the browser does around them.

Clarifying questions

Q1
Use casesWhat are the main use cases?
A returning user signs in with their phone number and a one-time code instead of a password. Both steps live on one route, and the user can go back and fix the number. Everything else on the screen links to another flow.
Q2
CountriesWhich countries and number formats are supported?
Assumption: any region the server’s SMS providers reach. The user picks a country (preselected from the browser’s locale) and types the number the way people write it locally. The client normalises it to E.164 for convenience; the server keeps the final list and answers unsupported_region otherwise.
Q3
ChannelsIs it SMS only, or also a voice call?
Assumption: SMS first. A voice call that reads the code aloud is offered once an SMS has failed, meaning after the first resend. Messaging apps and email codes are left out of this version.
Q4
Unknown numbersWhat happens when the number has no account?
Assumption: the start step answers the same way either way, so the form can’t be used to test which numbers are registered. Only after the code proves the user holds the number does verify say signup_required, and the user is handed to the sign-up flow.
Q5
Code lifetimeHow long does a code live, and how many tries does the user get?
Assumption, owned by the server: 6 digits, valid for 5 minutes, 5 attempts per challenge that a resend does not reset, and resend waits of 30, then 60, then 120 seconds, with at most 3 resends. The client never hardcodes these numbers; it shows what the server returns.
Q6
PrivacyIs the phone number a secret?
It is not a credential, but it is personal data that identifies someone. It never goes into URLs, analytics or error reports, and after the phone step it is shown only masked.
Q7
SecurityHow much should we trust an SMS code?
Not much on its own. Texts can be intercepted and numbers can be moved to another SIM. This is a convenience sign-in; the server scores risk and can demand a second factor, a bot check or refuse outright.
Q8
ScopeWhat happens after the code is accepted?
The same thing as after a password. The server sets the same session cookie, the store gets the viewer and Session Guard redirects to a safe next. A second-factor challenge or a sign-up handoff goes to those flows; this page does not implement them.

Functional requirements

#1User can choose a country, preselected from the browser’s locale, and type the number in their local format.
#2User can request a code by SMS and sees, masked, which number it went to.
#3User can type, paste or autofill the 6-digit code, and it submits when the last digit arrives.
#4User can request a new code once a visible countdown ends, and switch to a voice call after an SMS has failed.
#5User can go back and change the number without reloading or losing what they typed.
#6On success, user lands on the page they originally requested, or Home. If a second factor is required, they are taken to that flow.
#7A verified number with no account is handed to the sign-up flow.
#8User sees a clear message and a way forward for an invalid number, an unsupported country, a wrong, expired or exhausted code, rate limits, a bot check and network failures.
#9A user who already has a valid session is redirected away from this page.
#10User can switch to email or username login.

Non-functional requirements

No.AreaRequirementTarget / measure
01PerformanceThe phone step paints and accepts input almost immediately; the number parser never blocks first paint.
LCP < 1.5sInteractive < 2sRoute JS < 30 KBPhone parser loaded after first paint
Mid-range phone on 4G, compressed JS.
02Time to sign inFrom tapping Send code to landing signed in.
Code step < 1s after the tapp50 < 30s with autofill
Includes SMS delivery, which the client cannot control. Illustrative targets.
03Abuse resistanceNo SMS leaves without passing the server’s limits, so scripts get nothing out of the form.
0 sends that skip rate limitsBot check before risky sends
04Security and privacySMS is treated as a convenience factor, the number as personal data, the session as in the email login.
0 full numbers or codes in URLs, logs, analytics or storageSession in an HttpOnly cookie
05AccessibilityBoth steps work by keyboard and screen reader. The code field has one label and announces errors.
WCAG 2.2 AA
06AutofillThe code is offered without typing wherever the platform supports it.
Safari (iOS, macOS) keyboard suggestionChrome on Android via WebOTPPaste works everywhere
07InternationalisationEvery supported region in its own number format, translated copy, right-to-left layouts with numbers kept left-to-right.
All regions the server supportsAll supported locales

Scope

In scope
Phone step and code step on one route, with Change number
Parsing and normalising the number to E.164
Code field and SMS autofill
Resend countdown and voice fallback
Wrong, expired and exhausted codes, rate limits
Session handoff and safe redirect to next
Keeping the number out of URLs, logs and analytics
Out of scope
Email or username and password loginsibling page
Sign-up for a number with no accountsign-up flow
Two-factor after a passwordchallenge flow
Code generation, SMS gateways, fraud scoringserver black box
Native apps and the Android SMS Retriever APIseparate design
Recovery after losing the numberaccount recovery
Was this section helpful?

High level design.

The page reuses the email login’s Auth Store, Auth Service and Session Guard unchanged in spirit. What is new is a two-step machine driven by one piece of store state, the challenge, and an autofill helper that is pure progressive enhancement.

Component diagram

Click a card to see its block and connections in the diagram.

Phone login components

Phone login components. The numbered component cards that follow describe each part.
Phone login componentsComponents: 1. OtpLoginPage (Route shell and the two-step machine. Reads next, shows the phone step or the code step depending on whether the store holds a challenge, and keeps PhoneForm mounted so Change number can return to it.), 2. PhoneForm (CountryPicker, PhoneNumberField and the submit button. Formats as the user types, parses to E.164 on submit and dispatches the start.), 3. CodeForm (One code input, ResendControl with its countdown and voice option, and ChangeNumberLink. Auto-submits on the last digit and clears the code after every failure.), 4. Auth Store (App-wide auth state, shared with the email login: status, the OTP challenge, the error and the viewer. Never the full number, and never the code after submit.), 5. Auth Service (The only module that calls auth endpoints. Adds CSRF and an idempotency key, times out, maps responses to typed results and never retries a send on its own.), 6. Autofill Helper (Wraps WebOTP behind a feature check. Armed when the code step appears, aborted when it goes away. Without it, typing and pasting still work.), 7. Session Guard (Protects routes, validates next, sends signed-in users away and hands off to the two-factor and sign-up flows.), 8. Server (Validates the number, runs bot checks and rate limits, sends the SMS or places the call, checks codes and sets the session cookie.).

Browser

1OtpLoginPage/accounts/login/phone?next=/explore/

arm · abort · code back

start · verify · resend

challenge or typed error

status changes

POST /api/auth/otp/start
POST /api/auth/otp/verify
POST /api/auth/otp/resend

200 + Set-Cookie
HttpOnly · Secure · Lax

start · verify · resend requested

status · challenge · error

2PhoneForm
CountryPicker · PhoneNumberField
SubmitButton · FormErrorBanner

3CodeForm
CodeInput · one-time-code
ResendControl · ChangeNumberLink
StatusMessage (role=alert)

6Autofill Helper
WebOTP behind a feature check
AbortController per code step
reads the origin-bound SMS code

4Auth Store
{ status, challenge, error, viewer }

5Auth Service
CSRF · Idempotency-Key · timeout
error mapping · no automatic send retries

7Session Guard / Router
logged in → leave this page
success → safe next or Home
two-factor or sign-up → hand off

8Server
black box · OTP API
bot check · rate limits · risk
SMS and voice via carriers

Sequence diagram

The happy path on a phone with WebOTP, from typing the number to landing on the next page. Scroll sideways to follow it.

Phone login with SMS autofill, happy path

Phone login with SMS autofill, happy path, as an ordered list of steps:
Phone login with SMS autofill, happy path21 steps between User, PhoneForm, CodeForm, Autofill Helper, Auth Store, Auth Service, Server, Router. The steps are listed as text after the diagram.RouterServerAuth ServiceAuth StoreAutofill HelperCodeFormPhoneFormUservalidate, rate limit, risk check, send SMSstatus = awaiting_code, code step showsuser taps Allow, browser hands over the codestatus = authenticated, challenge droppedpick country, type number, Send code1parse → +120255501422otpStartRequested3start(phone, sms)4POST /api/auth/otp/start + X-CSRF-Token + Idempotency-Key5200 challenge + server_time6Challenge(masked_phone, expires_at, resend_available_at)7arm WebOTP8SMS ending “@example.com #123456” reaches the phone912345610otpVerifyRequested (sixth digit)11verify(challenge_id, code)12POST /api/auth/otp/verify13200 + Set-Cookie14Success(viewer)15status changed16redirect to next or Home17
  1. User → PhoneForm: pick country, type number, Send code
  2. PhoneForm → PhoneForm: parse → +12025550142
  3. PhoneForm → Auth Store: otpStartRequested
  4. Auth Store → Auth Service: start(phone, sms)
  5. Auth Service → Server: POST /api/auth/otp/start + X-CSRF-Token + Idempotency-Key
  6. Note over Server: validate, rate limit, risk check, send SMS
  7. Server → Auth Service (reply): 200 challenge + server_time
  8. Auth Service → Auth Store (reply): Challenge(masked_phone, expires_at, resend_available_at)
  9. Note over Auth Store: status = awaiting_code, code step shows
  10. CodeForm → Autofill Helper: arm WebOTP
  11. Server → Autofill Helper: SMS ending “@example.com #123456” reaches the phone
  12. Note over Autofill Helper: user taps Allow, browser hands over the code
  13. Autofill Helper → CodeForm (reply): 123456
  14. CodeForm → Auth Store: otpVerifyRequested (sixth digit)
  15. Auth Store → Auth Service: verify(challenge_id, code)
  16. Auth Service → Server: POST /api/auth/otp/verify
  17. Server → Auth Service (reply): 200 + Set-Cookie
  18. Auth Service → Auth Store (reply): Success(viewer)
  19. Note over Auth Store: status = authenticated, challenge dropped
  20. Auth Store → Router: status changed
  21. Router → User (reply): redirect to next or Home
How verify can answerAuth Service maps each response to a typed result; the tag shows who reacts.
CodeOutcomeKindWhat happensReacts
200authenticatedsuccessSession cookie set, viewer stored, redirect to a safe next or Home.7Session Guard
200two_factor_requiredchallengeNo session yet. Route to the two-factor flow with the challenge.7Session Guard
200signup_requiredchallengeNumber proven, no account. Hand off to sign-up with the signup token.7Session Guard
401code_incorrecterrorChallenge kept. Clear the input, focus it, say how many attempts are left.3CodeForm
410code_expired · too_many_attemptserrorChallenge ended. Back to the phone step with the number kept, ready to send a new code.1OtpLoginPage
ERRnetwork error / timeouterror“Check your connection”, code kept, Continue enabled. No automatic retry.3CodeForm
Was this section helpful?

Data model.

The full number and the code each live in one component and one request, and only briefly. What survives is a challenge the server describes, and the countdowns are derived from the server’s times.

Read left to right. The full number exists only in PhoneForm and the start request; the code only in CodeForm and the verify request. Fields ending in _at are server times, and step 06 is derived from them on the client.keywordmodelfieldprimitivevalue
// 01 · PhoneForm
// Component memory only. Kept while the code step shows, so Change number refills it.
type PhoneFormState = {
country: RegionCode // ISO region, e.g. US
nationalNumber: string // as typed, formatted for display
parseError?: ParseError
}
type ParseError =
| "invalid_for_region" | "not_a_number"
| "too_long" | "too_short"
↓ on submit, parse to E.164
// 02 · Auth Service
// Lives only for the request.
type StartRequest = {
bot_token?: string
channel: Channel
phone: string // E.164, e.g. +12025550142
}
// Lives only for the request.
type VerifyRequest = {
challenge_id: string
code: string
next: string
}
// 04 · CodeForm
// Cleared after every failure and when the step unmounts.
type CodeFormState = {
code: string // digits only, cut to code_length
hasAutoSubmitted: bool // one auto-submit per value
source: "typed" | "pasted" | "autofill"
}
↑ on submit, copy into VerifyRequest
// 03 · Auth Store
// Shared with the email login. Read by guards and the header.
type AuthState = {
challenge?: OtpChallenge
error?: AuthError
status: Status
viewer?: Viewer
}
type Status =
| "anonymous" | "authenticated"
| "awaiting_code" | "error"
| "sending" | "verifying"
// 05 · Cookie jar
// Set by the server on verify. JavaScript cannot read it.
cookie Session = {
csrftoken
sessionid // HttpOnly
}
// 06 · Derived on the client
// Recomputed from server times on every tick and on visibilitychange. Never stored.
type Timers = {
canResend: bool // secondsToResend ≤ 0
clockOffsetMs: number // server_time − Date.now() on receipt
secondsToExpiry: int // expires_at − (now + offset)
secondsToResend: int // resend_available_at − (now + offset)
}
// 03 · Auth Store, nested
// From the server. Mirrored to sessionStorage for this tab; holds no number and no code.
type OtpChallenge = {
attempts_left: int
challenge_id: string
channel: Channel
code_length: int
expires_at: timestamp // server time
masked_phone: string // +1 ••• ••• ••42
resend_available_at: timestamp // server time
voice_available: bool
}
// Client maps code to message.
type AuthError = {
attempts_left?: int
code: ErrorCode
message: string
retry_after_s?: int
}
type Channel =
| "sms" | "voice"
// Same shape as the email login.
type Viewer = {
full_name: string
id: string
profile_pic_url: url
username: string
}

Who owns each number the user sees

ValueSourceUsed for
expires_atServer“Code expires in 0:45” in the last minute, and knowing a submit would fail
resend_available_atServerThe resend countdown and when the button enables
server_timeServerThe clock offset, so a phone whose clock is wrong still counts correctly
attempts_leftServer“4 attempts left” after a wrong code; never a local counter
secondsToResendClient (derived)The countdown text, recomputed so a throttled background tab is right on return
masked_phoneServer“Sent to +1 ••• ••• ••42” on the code step
Was this section helpful?

Interface definition.

Three small POST requests and nothing pushed. The browser notices the SMS on its own through WebOTP, which is a device API rather than a transport. CSRF works as in the email login, with the token embedded in the server-rendered HTML.

Transport choice

Used:HTTP / RESTStart, verify and resend are each one request and one response.
Not used:WebSocket · SSEThe server has nothing to push. The SMS reaches the phone through the carrier, and the browser reads it locally.
Not used:Polling for delivery statusThe client doesn’t need to know an SMS was delivered. The countdown, the late-text message and the voice fallback cover slow texts.
Not used:GraphQLThree fixed-shape mutations. REST keeps the route bundle small, as on the sibling page.

Server to client APIs

1POST/api/auth/otp/start

Validates the number, runs rate limits and risk checks, and sends a code by SMS or voice. Returns the same challenge shape whether or not the number has an account.

Request
bot_token is sent only on the second call, after a bot check. The Idempotency-Key is kept until the number changes or the challenge ends, so a manual retry never sends twice.
Headers
Content-Type: application/jsonX-CSRF-Token: <token>Idempotency-Key: <uuid, one per number entered>
Credentials
same-origin cookies
{
"phone": "+12025550142",
"channel": "sms",
"device_id": "web-7f2c..."
}
Response
{
"challenge": {
"challenge_id": "ch_4f2a...",
"masked_phone": "+1 ••• ••• ••42",
"channel": "sms",
"code_length": 6,
"expires_at": "2026-09-27T10:05:00Z",
"resend_available_at": "2026-09-27T10:00:30Z",
"attempts_left": 5,
"voice_available": false
},
"server_time": "2026-09-27T10:00:00Z"
}
Identical for numbers with and without an account.
2POST/api/auth/otp/verify

Checks the code against the challenge. On success it sets the session cookie, exactly as the email login does.

Request
Headers
Content-Type: application/jsonX-CSRF-Token: <token>
Credentials
same-origin cookies
{
"challenge_id": "ch_4f2a...",
"code": "123456",
"next": "/explore/"
}
Response
{
"status": "authenticated",
"viewer": {
"username": "jane",
"id": "123",
"profile_pic_url": "https://img.example.net/a/u_123/1d0e5b7a42/150.webp",
"full_name": "Jane Doe"
},
"redirect_to": "/explore/"
}
Also setsSet-Cookie: sessionid=…; HttpOnly; Secure; SameSite=Lax
3POST/api/auth/otp/resend

Sends a new code for a live challenge, by SMS or voice. The previous code stops working, the expiry restarts and attempts_left is not reset.

Request
Headers
Content-Type: application/jsonX-CSRF-Token: <token>Idempotency-Key: <uuid, one per resend tap>
Credentials
same-origin cookies
{ "challenge_id": "ch_4f2a...", "channel": "voice" }
Response200
{
"challenge": {
"challenge_id": "ch_4f2a...",
"masked_phone": "+1 ••• ••• ••42",
"channel": "voice",
"code_length": 6,
"expires_at": "2026-09-27T10:06:30Z",
"resend_available_at": "2026-09-27T10:03:30Z",
"attempts_left": 4,
"voice_available": true
},
"server_time": "2026-09-27T10:01:30Z"
}
The waits grow with each resend (30 s, 60 s, 120 s in this design). The client shows whatever resend_available_at says.

Error cases

Every error has one shape. Auth Service reads the code, not the HTTP status, and the client reacts.

{ "error": { "code": "code_incorrect", "message": "That code didn’t work.", "attempts_left": 4 } }
HTTPTypeBody codeClient behaviour
400error
invalid_phone
Field error under the number: “Enter a valid phone number for United States.” Keep the value. This should be rare if client parsing matches the server.
422error
unsupported_region
Banner: “We can’t send codes to this country yet.” Offer the email or username login.
403challenge
challenge_required
No SMS was sent. Show the bot check; when it passes, call start again with bot_token and the same Idempotency-Key.
429error
rate_limited+ retry_after_s
Banner, and Send code or Resend disabled with a countdown from retry_after_s. Offer email login. Never retry on a timer.
401error
code_incorrect+ attempts_left
Challenge kept. Clear the input, focus it and announce “That code didn’t work. 4 attempts left.”
410error
code_expired
Challenge ended. Back to the phone step with the number kept: “That code has expired. Send a new one.”
410error
too_many_attempts
Challenge ended. As for code_expired, with “Too many incorrect codes. Send a new one.” The next start may itself be rate limited.
403retry
csrf_failed
Refresh the CSRF token once, then retry once. Safe even for start and resend, because the server rejects the request before it sends anything.
5xxnetwork
timeout · offline
“We couldn’t connect. Check your connection and try again.” Keep the values. Never retry start or resend automatically; a manual retry reuses the Idempotency-Key, so the server doesn’t send twice.

Client to client communication

Each field reports to its form through props and callbacks; the two forms reach the rest of the app only through Auth Store actions. Choose a scenario and step through it. Every step names the action, what it sets in the store and what the screen does.

Step 1 of 5
Jane keeps United States and types 202 555 0142. PhoneForm formats it as she types; nothing reaches the store yet.
example.com/accounts/login/phone?next=/explore/
Log in with your phone
Country
United States (+1)
Phone number
202-555-0142
Send code
Actions dispatched
Nothing yet. The user is still on the phone step, so nothing has reached the store.
Auth Store
statusanonymous
challengenull
errornull
viewernull
All actions
auth/otpStartRequestedPhoneForm, on valid submit{ phone: "+12025550142", channel: "sms" }
auth/otpChallengeStartedAuth Service, after start or resend{ challenge, serverTime }
auth/otpStartFailedAuth Service{ error: AuthError }
auth/otpVerifyRequestedCodeForm, on the last digit or Continue{ challenge_id, code }
auth/otpVerifyFailedAuth Service, challenge still live{ error: AuthError }
auth/otpChallengeEndedAuth Service, on 410 or Change number{ reason }
auth/otpResendRequestedResendControl, once the countdown ends{ challenge_id, channel }
auth/loginSucceededAuth Service{ viewer, redirectTo }
auth/errorDismissedPhoneForm or CodeForm, on the next editnone

UI component API

PhoneForm is reused by the login page and by the login modal a logged-out user sees when they try to like or follow. Click a prop to see what it controls.

<PhoneForm
/>
Prop
defaultCountry
Default
from navigator.languages
Why
Preselects the likely country. The user can always change it, and pasting a number that starts with + switches it.
Preview
Log in with your phone
Country
United Kingdom (+44)
Phone number
Send code
A browser set to English (United Kingdom) starts on the United Kingdom.
Child components
CountryPicker
valueonChangeregionslabel="Country"
A searchable list of region names with their dial codes. Its value is the ISO region, not the dial code, because several regions share +1.
PhoneNumberField
valueonChangeonBlurerrorlabel="Phone number"type="tel"autoComplete="tel-national"inputMode="tel"
Formats as you type without jumping the caret. If autofill or paste brings a number starting with +, it switches the country and keeps only the national part.
Was this section helpful?

Optimizations.

A phone login can’t be tuned everywhere at once. The rubric ranks the candidate areas, and the three that decide whether people get in get a deep dive each.

Priority rubric

Every area gets a score from 1 to 3 on each question. The three highest are picked; the others are covered by the sibling page or the app shell, or matter less here.

RankAreaImpact if it failsUsers affectedHow often it mattersScoreDecision
1Abuse and security3 of 33 of 33 of 39Deep diveEvery send costs money and every sign-in form is a target. A leak or a pumping attack hurts users and the business at once.
2Code entry UX and accessibility2 of 33 of 33 of 38Deep diveEvery user types, pastes or autofills a code on every sign-in. A fiddly field loses people at the last step.
3Delivery and resend3 of 32 of 33 of 38Deep diveLate or lost texts are common. When the recovery path is unclear, the user is stuck.
4First-load performance2 of 33 of 32 of 37SkippedHandled as on the sibling page. The one new cost, the phone metadata, is loaded after first paint.
5Internationalisation of numbers2 of 32 of 32 of 36SkippedReal, but mostly solved by the parsing library, the country picker and the app-wide i18n layer.
6Offline support1 of 31 of 31 of 33SkippedSigning in needs the network and the phone network by definition.
1

Deep dive: abuse and security

Each item pairs a concrete attack with the layer that stops it. Most of the defence is on the server; the client’s job is to not undo it.

No.ConcernRiskApproachOwned by
01SMS pumping and toll fraudScripts submit premium-rate or unused numbers, so every SMS earns a fraudster a cut and costs the product money.The server limits sends per number, per number prefix, per IP and per device, restricts regions, and demands a bot check before any send it finds risky. The client only renders the check and never sends without the server’s go-ahead.8Server2PhoneForm
02Brute force on the codeOne guess has a one-in-a-million chance, but unlimited guesses would find the code.5 attempts per challenge, not reset by resend, then the challenge ends. Codes expire after 5 minutes, and per-IP limits apply across challenges.8Server3CodeForm
03Enumeration of registered numbersDifferent answers or timings reveal who has an account.Start answers identically, with the same message and similar timing. Account existence comes out only after the code proves ownership (signup_required).8Server
04SIM swap and interceptionSomeone who takes over the number or reads its texts can sign in.Treat SMS as a convenience sign-in. The server weighs risk (new device, recent number change) and can require the two-factor flow or refuse.8Server7Session Guard
05Phishing relayA look-alike site asks for the code and relays it in real time.The origin-bound last line means autofill works only on example.com, so a missing suggestion is a warning sign, and the SMS says never to share the code. It cannot stop a user who types the code in.6Autofill Helper8Server
06The number as personal dataThe full number leaks into URLs, logs, analytics, error reports or storage.The number lives only in PhoneForm memory and the start body, and is masked everywhere after that. Analytics get the region and the outcome code, error reports strip request bodies, and sessionStorage holds only the masked challenge.2PhoneForm5Auth Service
07The code in telemetrySession replay or error tools record the typed code.Mask the code input in replay tools, strip verify bodies from error reports, and log outcome codes only.3CodeForm5Auth Service
08Session and CSRFSession theft or cross-site requests, as on any login.The same HttpOnly, Secure, SameSite=Lax session cookie and double-submit CSRF token as the email login.8Server5Auth Service
09Open redirectnext sends a freshly signed-in user to a phishing site.The same rule as the sibling. Only same-origin paths starting with a single slash are accepted; anything else goes Home.7Session Guard
2

Deep dive: code entry and autofill

The best code field is the one the user never types into. When they do have to type, it should accept whatever they paste and tell a screen reader exactly what happened.

Inputs in the code field1Taps to sign in with WebOTP1 (Allow)Pasted formats accepted123456 · 123 456 · 123-456
No.ConcernRiskApproachOwned by
01One input, not sixSeparate boxes per digit break paste, autofill and screen readers, and need fragile focus-jumping code.A single input with autocomplete="one-time-code". If the design wants boxes, they are CSS over that one input, not six elements.3CodeForm
02KeyboardA full keyboard slows entry, and type=number adds spinners and can drop leading zeros.type="text" with inputmode="numeric" and pattern="[0-9]*", which brings up the digit pad and keeps the value a string.3CodeForm
03Paste and formattingA pasted code with a space or dash is rejected or cut short.Strip everything that isn’t a digit on input, then cut to code_length. No maxlength attribute, which would truncate “123 456” before cleaning.3CodeForm
04Keyboard suggestion on Apple platformsUsers switch apps to copy the code and lose their place.With the one-time-code token, Safari offers the code from Messages above the keyboard. Nothing else is needed.3CodeForm
05WebOTPA request left pending fills a step the user has left, or fails on browsers without the API.A feature check first. The Autofill helper arms on step mount and aborts through its AbortController on Change number, on unmount and after a manual submit. Errors are ignored, because typing still works.6Autofill Helper3CodeForm
06Origin-bound SMSAutofill offers the code to the wrong site, or not at all.The server ends every SMS with the line “@example.com #123456”, so browsers offer the code only on that origin.8Server
07Auto-submit on the last digitDouble submits, or a loop resubmitting a wrong code.Submit once when the cleaned value reaches code_length, whatever its source. Don’t auto-submit the same value again after an error. The Continue button stays for anyone who wants it.3CodeForm
08Focus and announcementsScreen reader users don’t know where the code went or why it failed.Focus the input when the step appears. The label names the length, the masked number is linked with aria-describedby, and errors go to a role="alert" region. The countdown is announced only when resend becomes available, not every second.3CodeForm1OtpLoginPage

The code field and the SMS

<label for="otp-code">6-digit code</label>
<input
  id="otp-code"
  name="code"
  type="text"
  autocomplete="one-time-code"
  inputmode="numeric"
  pattern="[0-9]*"
  aria-describedby="otp-sent-to otp-error"
/>
<p id="otp-sent-to">Sent to +1 ••• ••• ••42</p>
<p id="otp-error" role="alert"></p>
123456 is your Example login code.
Don't share it with anyone.

@example.com #123456

The autofill helper

// Progressive enhancement: the plain input works without this.
export function armSmsAutofill(onCode: (code: string) => void): () => void {
  if (!('OTPCredential' in window)) return () => {};
  const controller = new AbortController();
  navigator.credentials
    .get({ otp: { transport: ['sms'] }, signal: controller.signal } as CredentialRequestOptions)
    .then((credential) => {
      const code = (credential as { code?: string } | null)?.code;
      if (code) onCode(code);
    })
    .catch(() => {
      // Aborted, denied or unsupported: typing and pasting still work.
    });
  // CodeForm calls this on Change number, on unmount and after a manual submit.
  return () => controller.abort();
}
3

Deep dive: delivery and resend

Texts arrive late or not at all more often than anyone would like. The page has to keep the user calm, stop them making things worse, and offer another way before they give up.

First resendafter 30 sLater waits60 s, then 120 sVoice offeredafter one failed SMSCode lifetime5 min
No.ConcernRiskApproachOwned by
01A countdown the server agrees withA local timer drifts, resets on reload and lets the user tap too early, only to get a 429.Derive the countdown from resend_available_at, corrected by the offset from server_time, and recompute on visibilitychange.3CodeForm4Auth Store
02Escalating waitsA fixed short wait invites repeated taps and repeated charges.The server lengthens the wait after each resend and caps resends per challenge. The client shows whatever it is told and never hardcodes the steps.8Server3CodeForm
03Voice fallbackSome numbers and carriers never receive the text.Once voice_available is true (after one resend), ResendControl offers Call me instead. The call reads the digits slowly, twice.8Server3CodeForm
04What to say while the text is lateThe user assumes it failed and gives up, or taps Send repeatedly.From the first second, say texts can take up to a minute and show the masked number with a Change number link, in case it was mistyped. After a resend, say the previous code no longer works.3CodeForm
05A tab discarded while reading the SMSA mobile browser drops the tab when the user switches to Messages, and a reload loses the step.Mirror the challenge (masked number and times, never the full number or the code) to sessionStorage, so a reload in the same tab returns to the code step.4Auth Store1OtpLoginPage
06Background timersThrottled timers in a hidden tab show a stale countdown on return.Timers only trigger a re-render; the value always comes from the timestamps, so it is right the moment the tab is visible again.3CodeForm
Was this section helpful?

Trade-offs.

For each decision the option this design picks comes first, and the alternatives stay listed with their pros and cons, so the choice can be argued with.

01
The code field
Chosen:One input, optionally styled as boxes
  • Pro:Paste and autofill work unchanged
  • Pro:One label for screen readers
  • Pro:No focus-jumping code
Downside we accept:
  • Con:Drawing boxes over one input takes careful CSS
Ruled out:One input per digit

Paste and autofill need custom handling; Six unlabeled fields for screen readers; Backspace and focus bugs

02
Submitting the code
Chosen:Auto-submit on the last digit, button kept
  • Pro:Autofill signs in with no extra tap
  • Pro:The button remains for anyone who wants it
Downside we accept:
  • Con:A typo is submitted before the user sees it, costing an attempt
Ruled out:Explicit button only

An extra tap on every sign-in; Wastes the benefit of autofill

03
Delivery channel
Chosen:SMS first, voice call after an SMS has failed
  • Pro:Works on any phone
  • Pro:Autofill works with SMS
  • Pro:Voice reaches numbers that can’t receive texts
Downside we accept:
  • Con:Both cost money per send
  • Con:Both ride the phone network, open to interception and SIM swaps
Ruled out:Voice first

No autofill; Intrusive, and slow to listen to

Ruled out:Another channel, such as a messaging app or email

Not everyone has it; More integrations to build and secure

04
Resend waits
Chosen:Escalating waits, decided by the server
  • Pro:Fast help for the first delay
  • Pro:Discourages repeated charges and abuse
Downside we accept:
  • Con:Users waiting on a genuinely lost text wait longer each time
Ruled out:One fixed wait

Either too slow the first time or too permissive for abuse

05
Numbers with no account
Chosen:Uniform response; reveal only after the code is verified
  • Pro:The form can’t be used to test which numbers are registered
  • Pro:Ownership is proven before anything is revealed
Downside we accept:
  • Con:A text is sent to numbers with no account, costing money
  • Con:The user learns there is no account only after entering the code
Ruled out:Say “no account for this number” at the start

Lets anyone enumerate registered numbers

06
Default country
Chosen:From the browser’s locale, always changeable
  • Pro:No extra request
  • Pro:Right for most users
  • Pro:Reveals nothing new about them
Downside we accept:
  • Con:Travellers and expats must change it
Ruled out:From IP geolocation

An extra lookup; Wrong behind VPNs and for travellers; Adds a location-tracking concern

Ruled out:No default; always ask

An extra step for everyone

07
Code length
Chosen:6 digits
  • Pro:A one-in-a-million guess, combined with 5 attempts and a short lifetime
  • Pro:Easy to hold in short-term memory
Downside we accept:
  • Con:Slightly more to type than 4
Ruled out:4 digits

Only 10,000 values, too few against brute force across many accounts

Ruled out:8 digits

More typos and more failed attempts; Little gain when attempts are already capped

Was this section helpful?
Builds on this
Link a device
Read next