Design an OTP service at scale
The interviewer asks
“Design a one-time password service used by all your company's apps for login, phone verification and payment confirmation. It sends OTPs by SMS (with WhatsApp and voice fallback) to Indian and international numbers, verifies them, and must hold up when 5,000 users try to log in per second at the start of a cricket match.”
What a strong answer looks like
A strong answer treats OTP as a security system first and a messaging system second. Generate 6 digits with a CSPRNG, bind them to a request id, purpose and phone number, store only an HMAC of the code in Redis with a 3-5 minute TTL, an attempt counter and single-use semantics enforced atomically (a Lua script that checks and deletes). Layer rate limits per phone, device, IP and destination prefix, because the biggest real-world cost is SMS pumping fraud, not brute force. Delivery goes through a priority lane with multiple SMS aggregators routed by live per-operator success rate, DLT-registered templates in India, and fallbacks to WhatsApp or voice; Android SMS Retriever and WebOTP autofill improve conversion. Measure the OTP funnel (requested → delivered → verified) per provider, operator and country, since a falling conversion rate is the first signal of both delivery problems and fraud.
Concepts to mention
1 Requirements
Functional
- Send an OTP to a phone number (or email) for a given purpose: login, phone change, payment confirmation
- Verify an OTP submitted by the user; each code works once and only for its purpose and request
- Resend with cooldown; fallback to WhatsApp or voice call when SMS is not delivered
- Autofill support on Android (SMS Retriever API) and web (WebOTP)
- Per-tenant (internal app) configuration: code length, TTL, max attempts, channels, templates
- Abuse controls and an audit trail of every send and verify
Non-functional
- Send API p99 under 100 ms (handing off to the provider happens async); SMS delivered p95 under 10 s in India
- Peak 5k sends/s and 8k verifies/s at match start or sale start; ~20M OTPs/day
- Verify API p99 under 50 ms and 99.99% available: if verify is down, nobody can log in
- Brute-force probability per OTP below 1 in 100,000 (6 digits, max 5 attempts)
- SMS cost and fraud visible per tenant, country and prefix; alert on anomalies within minutes
- India: DLT-registered sender ID and templates; data stored in India
Out of scope (say it out loud)
- Authenticator-app TOTP and passkeys (mention as alternatives)
- Full login and session management
- Building our own SMS gateway
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/otp/send | {tenant, purpose, phone, channel_pref, locale, device_id, app_hash?}; returns {request_id, expires_in, resend_after} |
POST/v1/otp/verify | {request_id, code}; returns {verified: true, token} or error with attempts_left; token is a short-lived signed proof for the calling service |
POST/v1/otp/resend | {request_id, channel?}; enforces cooldown; may switch provider or channel |
POST/v1/webhooks/dlr/{provider} | Delivery receipts from SMS aggregators and WhatsApp |
GET/admin/otp/metrics?tenant=&country=&provider= | Funnel: sent, delivered, verified, cost, by dimension |
4 Data model
otp stateRedis (cluster, replicated)key otp:{request_id} → {phone_hashpurposetenantcode_hmacattemptscreated_atchannelprovider_msg_id}; TTL = OTP lifetimeHash tag {request_id} keeps one OTP on one shard. Verification is a Lua script: compare HMAC, increment attempts, delete on success, all atomic.
rate-limit countersRedisrl:phone:{phone_hash}:{window}rl:ip:{ip}rl:device:{id}rl:prefix:{country+prefix}rl:tenant:{id}Sliding window or token bucket; per-prefix counters detect pumping of number ranges.
otp_auditCassandra / ClickHouserequest_idtenantpurposephone_hashcountryoperatorchannelprovidersent_atdelivered_atverified_atattemptscostrisk_scoreAppend-only events for funnel analytics, fraud review and disputes; no plaintext codes, phone stored hashed or encrypted.
provider routing tablePostgres + in-memorycountryoperatorchannel → ordered providersweightscostlive success rateWeights updated every minute from delivery receipts.
templatesPostgrestenantpurposelocalechannelbodydlt_template_idsender_idBody must match the DLT-registered template exactly, including the autofill hash line.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Login OTP over SMS
- 1App calls /otp/send with phone, purpose=login, device_id and its 11-character SMS Retriever app hash
- 2Risk checks run in a few ms: per-phone (3 sends per 15 min), per-device, per-IP, per-prefix and per-tenant limits; country allow-list; device attestation (Play Integrity) for suspicious traffic
- 3Service generates a 6-digit code with a CSPRNG, stores HMAC(pepper, request_id + code) in Redis with a 300 s TTL and attempts=0
- 4It enqueues a send job on the critical lane and returns request_id and resend_after=30 s in ~20 ms
- 5Sender picks the aggregator with the best live success rate for the user's operator (Jio, Airtel, Vi) and submits the DLT template text ending with the app hash
- 6Android's SMS Retriever reads the message and autofills; the app calls /otp/verify with request_id and code
- 7A Lua script compares HMACs in constant time, increments attempts, deletes the key on success, and returns a signed short-lived token the login service accepts
SMS not delivered
- 1No delivery receipt arrives within ~15 s, or the receipt says failed
- 2When the user taps Resend after the 30 s cooldown, the service reuses the same request_id and code (so a late first SMS still works) and routes via a different aggregator
- 3On the second failure it offers WhatsApp (authentication template) or a voice call reading the code
- 4Each attempt is logged; per-provider failure rates update routing weights for everyone
SMS pumping attack
- 1Bots call /otp/send for thousands of numbers in a premium-rate international range
- 2Per-prefix velocity and the 'sent but never verified' ratio for that prefix spike within minutes
- 3Risk service blocks the prefix range, requires CAPTCHA or attestation for that country, and alerts; daily international spend cap stops the bleeding
7 Deep dives
Generating, storing and verifying codes safely
Use a cryptographically secure generator (crypto/rand, SecureRandom), never Math.random or a time-based counter. Six digits is the norm; with at most 5 verify attempts per OTP the guessing chance is 5 in a million. Store the code as HMAC-SHA256 with a secret pepper kept in KMS, not as plaintext and not as a plain hash: a bare SHA-256 of a 6-digit code is reversed by trying all million values in milliseconds, so the secret key is what protects it if Redis data leaks. Bind the HMAC input to request_id and purpose so a login OTP cannot confirm a payment. Verification must be atomic: a Lua script in Redis reads the record, checks attempts, compares, increments attempts on failure, and deletes on success in one step; otherwise two parallel verify calls can both succeed or exceed the attempt limit. Compare in constant time. Return a generic error for wrong code and expired code to avoid leaking state, but show attempts left for usability. On success, return a short-lived signed token (JWT or opaque, 2 minutes) that the calling service checks, rather than the calling service trusting a boolean over the network.
Abuse: brute force, enumeration and SMS pumping
Brute force on one OTP is handled by attempt limits, but an attacker can request a new OTP and get 5 fresh guesses, so limit sends per phone (e.g. 3 per 15 minutes, 10 per day) and lock verification for a phone after ~10 failures across OTPs. The more expensive attack is SMS pumping (artificially inflated traffic): fraudsters own or partner with premium-rate number ranges, mostly international, and use your OTP endpoint to send thousands of SMS they get paid for. Twitter reported losing tens of millions of dollars a year this way. Defences: allow-list the countries you actually serve; per-prefix and per-country velocity limits; watch the conversion rate (verified / sent) per prefix, since pumped numbers never verify; device attestation (Play Integrity, App Attest) or CAPTCHA before sending when risk is high; and hard spend caps per country per day. Also prevent account enumeration: the send API should respond the same whether or not the phone has an account.
Delivery: aggregators, routing and India's DLT rules
Delivery success varies by aggregator, operator, region and time of day, so use two or three aggregators and route each message to the one with the best recent success rate and latency for the destination operator, measured from delivery receipts over the last few minutes. A circuit breaker drops an aggregator whose submit errors or receipt delays spike. OTPs go on a dedicated priority lane with reserved provider throughput (TPS), so a marketing campaign on the shared notification platform can never delay them. In India, TRAI's DLT system requires a registered entity, sender header and content template; the operator scrubs the message against the template, so the rendered text must match exactly, including the autofill hash line, or it is silently dropped. OTP and other service-implicit messages can be sent at any hour, unlike promotional ones. Fallbacks: WhatsApp authentication templates (often cheaper and very reliable in India) and voice calls for users with SMS issues. On resend, keep the same code so a slow first SMS remains valid.
Funnel metrics and conversion
Success of an OTP system is measured by how many users get through, not how many messages were sent. Track the funnel per tenant, purpose, country, operator, channel and provider: requested, accepted by provider, delivered (receipt), verified, and time between each step. Typical healthy delivery-to-verify conversion for login is high; a drop for one operator points at a delivery problem, while a drop for one prefix with high volume points at fraud. Autofill moves conversion and speed the most: Android's SMS Retriever API needs the message to end with an 11-character hash of the app's signing key and no SMS permission; the web WebOTP API needs a final line like '@example.com #123456'. Both remove typing errors and resends, which also cuts cost. Store this funnel in an analytics store (ClickHouse) fed by events from the service and from delivery-receipt webhooks, and alert on burn rate against an SLO such as '99% of OTPs verified-or-expired within 60 s'.
8 Trade-offs
Redis vs a database for OTP state
OTP state is short-lived and hot, with TTLs and atomic scripts, which fits Redis perfectly. Losing it means users request a new code, an acceptable failure. Use a replicated Redis cluster; keep the audit trail in a durable store separately.
SMS OTP vs stronger factors
SMS is universal in India and required by many flows, but vulnerable to SIM swap and phishing. Offer passkeys, TOTP or in-app device binding (as UPI apps do) for high-risk actions, and use SMS OTP as one signal among several.
Same code on resend vs new code
Reusing the code means a late first SMS still works and avoids confusion; a new code limits exposure but invalidates messages already in flight. Reuse within the TTL, with a cap on total sends.
Fail open vs fail closed on risk checks
If the risk service is down, failing closed blocks all logins; failing open invites abuse. Fail open with tightened static per-phone and per-IP limits in the OTP service itself, and alert.
9 Bottlenecks
Provider TPS at match start
Aggregators cap throughput per account. Spread across providers and accounts, reserve OTP TPS, and pre-agree burst limits before known events.
Hot Redis shard
Rate-limit keys for one big IP (carrier-grade NAT puts many mobile users behind one IP) get hot. Keep IP limits loose and lean on device and phone limits; shard counters with hash tags.
Verify storms from bots
Rejected guesses still cost Redis calls. Rate-limit verify per request_id, phone and IP at the gateway, and cache 'locked' states locally for a few seconds.
10 Failure handling
Primary SMS aggregator outage
Circuit breaker opens on submit errors or missing receipts; traffic shifts to the secondary within seconds. Users who already got 'sent' can resend, which uses the new route.
Redis primary fails
Replica is promoted in seconds; OTPs written in the last moment may be lost, so those users see 'expired, resend'. Rate-limit counters may reset, so keep conservative static limits as a backstop.
Operator-wide SMS delays
Receipts show one operator lagging; routing shifts to WhatsApp or voice for that operator's users, and the app shows the alternative channel earlier.
11 What impresses
- Explaining why a plain hash of a 6-digit OTP is useless and a keyed HMAC is needed
- Bringing up SMS pumping and the verified/sent ratio per prefix
- Atomic verify-and-delete to stop parallel verification races
- Reusing the same code on resend through a different provider
- Measuring the funnel end to end instead of 'messages sent'
- Mentioning SIM-swap risk and stronger factors for high-value actions
12 Mistakes
- Using a non-cryptographic random generator or predictable codes
- Storing OTPs in plaintext or logging them
- No limits on sends per phone, allowing unlimited guesses via resends
- Check-then-delete in two calls, allowing double use
- One SMS provider and no fallback
- Ignoring international pumping fraud and cost controls
13 Follow-ups
Lessons this design uses
Practice questions
- OTP SMS and marketing SMS go through the same queue. During a marketing campaign of 50 lakh messages, users wait 20 minutes for OTPs. How do you fix it?
- Your OTP service calls an SMS gateway that has no idempotency support. When the gateway is slow, users get two or three OTPs, and your SMS bill spikes. How do you handle a non-idempotent external dependency?
- Your app uses phone number + SMS OTP login. Your SMS bill tripled last month, with lots of OTPs sent to numbers in other countries, and some accounts were brute-forced. How do you redesign the OTP flow?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.