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.
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
Functional requirements
Non-functional requirements
| No. | Area | Requirement | Target / measure |
|---|---|---|---|
| 01 | Performance | The 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. |
| 02 | Time to sign in | From 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. |
| 03 | Abuse resistance | No 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 |
| 04 | Security and privacy | SMS 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 |
| 05 | Accessibility | Both steps work by keyboard and screen reader. The code field has one label and announces errors. | WCAG 2.2 AA |
| 06 | Autofill | The code is offered without typing wherever the platform supports it. | Safari (iOS, macOS) keyboard suggestionChrome on Android via WebOTPPaste works everywhere |
| 07 | Internationalisation | Every 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
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
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
- User → PhoneForm: pick country, type number, Send code
- PhoneForm → PhoneForm: parse → +12025550142
- PhoneForm → Auth Store: otpStartRequested
- Auth Store → Auth Service: start(phone, sms)
- Auth Service → Server: POST /api/auth/otp/start + X-CSRF-Token + Idempotency-Key
- Note over Server: validate, rate limit, risk check, send SMS
- Server → Auth Service (reply): 200 challenge + server_time
- Auth Service → Auth Store (reply): Challenge(masked_phone, expires_at, resend_available_at)
- Note over Auth Store: status = awaiting_code, code step shows
- CodeForm → Autofill Helper: arm WebOTP
- Server → Autofill Helper: SMS ending “@example.com #123456” reaches the phone
- Note over Autofill Helper: user taps Allow, browser hands over the code
- Autofill Helper → CodeForm (reply): 123456
- CodeForm → Auth Store: otpVerifyRequested (sixth digit)
- Auth Store → Auth Service: verify(challenge_id, code)
- Auth Service → Server: POST /api/auth/otp/verify
- Server → Auth Service (reply): 200 + Set-Cookie
- Auth Service → Auth Store (reply): Success(viewer)
- Note over Auth Store: status = authenticated, challenge dropped
- Auth Store → Router: status changed
- Router → User (reply): redirect to next or Home
| Code | Outcome | Kind | What happens | Reacts |
|---|---|---|---|---|
| 200 | authenticated | success | Session cookie set, viewer stored, redirect to a safe next or Home. | 7Session Guard |
| 200 | two_factor_required | challenge | No session yet. Route to the two-factor flow with the challenge. | 7Session Guard |
| 200 | signup_required | challenge | Number proven, no account. Hand off to sign-up with the signup token. | 7Session Guard |
| 401 | code_incorrect | error | Challenge kept. Clear the input, focus it, say how many attempts are left. | 3CodeForm |
| 410 | code_expired · too_many_attempts | error | Challenge ended. Back to the phone step with the number kept, ready to send a new code. | 1OtpLoginPage |
| ERR | network error / timeout | error | “Check your connection”, code kept, Continue enabled. No automatic retry. | 3CodeForm |
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.
Who owns each number the user sees
| Value | Source | Used for |
|---|---|---|
| expires_at | Server | “Code expires in 0:45” in the last minute, and knowing a submit would fail |
| resend_available_at | Server | The resend countdown and when the button enables |
| server_time | Server | The clock offset, so a phone whose clock is wrong still counts correctly |
| attempts_left | Server | “4 attempts left” after a wrong code; never a local counter |
| secondsToResend | Client (derived) | The countdown text, recomputed so a throttled background tab is right on return |
| masked_phone | Server | “Sent to +1 ••• ••• ••42” on the code step |
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
Server to client APIs
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.
Checks the code against the challenge. On success it sets the session cookie, exactly as the email login does.
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.
Error cases
Every error has one shape. Auth Service reads the code, not the HTTP status, and the client reacts.
| HTTP | Type | Body code | Client behaviour |
|---|---|---|---|
| 400 | error | 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. |
| 422 | error | unsupported_region | Banner: “We can’t send codes to this country yet.” Offer the email or username login. |
| 403 | challenge | challenge_required | No SMS was sent. Show the bot check; when it passes, call start again with bot_token and the same Idempotency-Key. |
| 429 | error | 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. |
| 401 | error | code_incorrect+ attempts_left | Challenge kept. Clear the input, focus it and announce “That code didn’t work. 4 attempts left.” |
| 410 | error | code_expired | Challenge ended. Back to the phone step with the number kept: “That code has expired. Send a new one.” |
| 410 | error | 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. |
| 403 | retry | 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. |
| 5xx | network | 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.
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.
- 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.
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.
| Rank | Area | Impact if it fails | Users affected | How often it matters | Score | Decision |
|---|---|---|---|---|---|---|
| 1 | Abuse and security | 3 of 3 | 3 of 3 | 3 of 3 | 9 | Deep 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. |
| 2 | Code entry UX and accessibility | 2 of 3 | 3 of 3 | 3 of 3 | 8 | Deep diveEvery user types, pastes or autofills a code on every sign-in. A fiddly field loses people at the last step. |
| 3 | Delivery and resend | 3 of 3 | 2 of 3 | 3 of 3 | 8 | Deep diveLate or lost texts are common. When the recovery path is unclear, the user is stuck. |
| 4 | First-load performance | 2 of 3 | 3 of 3 | 2 of 3 | 7 | SkippedHandled as on the sibling page. The one new cost, the phone metadata, is loaded after first paint. |
| 5 | Internationalisation of numbers | 2 of 3 | 2 of 3 | 2 of 3 | 6 | SkippedReal, but mostly solved by the parsing library, the country picker and the app-wide i18n layer. |
| 6 | Offline support | 1 of 3 | 1 of 3 | 1 of 3 | 3 | SkippedSigning in needs the network and the phone network by definition. |
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. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | SMS pumping and toll fraud | Scripts 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 |
| 02 | Brute force on the code | One 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 |
| 03 | Enumeration of registered numbers | Different 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 |
| 04 | SIM swap and interception | Someone 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 |
| 05 | Phishing relay | A 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 |
| 06 | The number as personal data | The 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 |
| 07 | The code in telemetry | Session 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 |
| 08 | Session and CSRF | Session 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 |
| 09 | Open redirect | next 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 |
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.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | One input, not six | Separate 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 |
| 02 | Keyboard | A 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 |
| 03 | Paste and formatting | A 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 |
| 04 | Keyboard suggestion on Apple platforms | Users 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 |
| 05 | WebOTP | A 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 |
| 06 | Origin-bound SMS | Autofill 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 |
| 07 | Auto-submit on the last digit | Double 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 |
| 08 | Focus and announcements | Screen 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 #123456The 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();
}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.
| No. | Concern | Risk | Approach | Owned by |
|---|---|---|---|---|
| 01 | A countdown the server agrees with | A 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 |
| 02 | Escalating waits | A 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 |
| 03 | Voice fallback | Some 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 |
| 04 | What to say while the text is late | The 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 |
| 05 | A tab discarded while reading the SMS | A 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 |
| 06 | Background timers | Throttled 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 |
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.
- Pro:Paste and autofill work unchanged
- Pro:One label for screen readers
- Pro:No focus-jumping code
- Con:Drawing boxes over one input takes careful CSS
Paste and autofill need custom handling; Six unlabeled fields for screen readers; Backspace and focus bugs
- Pro:Autofill signs in with no extra tap
- Pro:The button remains for anyone who wants it
- Con:A typo is submitted before the user sees it, costing an attempt
An extra tap on every sign-in; Wastes the benefit of autofill
- Pro:Works on any phone
- Pro:Autofill works with SMS
- Pro:Voice reaches numbers that can’t receive texts
- Con:Both cost money per send
- Con:Both ride the phone network, open to interception and SIM swaps
No autofill; Intrusive, and slow to listen to
Not everyone has it; More integrations to build and secure
- Pro:Fast help for the first delay
- Pro:Discourages repeated charges and abuse
- Con:Users waiting on a genuinely lost text wait longer each time
Either too slow the first time or too permissive for abuse
- Pro:The form can’t be used to test which numbers are registered
- Pro:Ownership is proven before anything is revealed
- 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
Lets anyone enumerate registered numbers
- Pro:No extra request
- Pro:Right for most users
- Pro:Reveals nothing new about them
- Con:Travellers and expats must change it
An extra lookup; Wrong behind VPNs and for travellers; Adds a location-tracking concern
An extra step for everyone
- Pro:A one-in-a-million guess, combined with 5 attempts and a short lifetime
- Pro:Easy to hold in short-term memory
- Con:Slightly more to type than 4
Only 10,000 values, too few against brute force across many accounts
More typos and more failed attempts; Little gain when attempts are already capped