Design problems
Platform & infrastructureMediumSDE-2 30 min

Design an OTP service at scale

Asked at
CREDDream11PaytmPhonePeRazorpaySwiggy

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

CSPRNG 6-digit codes with short TTLHMAC with secret pepper, not plaintext or bare hashBind OTP to request id and purposeAtomic single-use verification (Lua script)Attempt limits plus send limits per phone, device, IP, prefixSMS pumping fraud and conversion-rate monitoringMultiple aggregators routed by per-operator success ratePriority lane with reserved provider throughputDLT templates and sender IDs in IndiaAutofill: SMS Retriever and WebOTPFallback channels: WhatsApp, voice

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

Send rate~230/s average, 5k/s peak20M/day ÷ 86,400 ≈ 230/s; a big match or flash sale start concentrates logins into a few minutes (~20×)
Verify rate~8k/s peak~1.3 verify calls per OTP (typos, autofill retries) plus bot attempts that must be rejected cheaply
Redis memory~1 GBLive OTPs = 5k/s × 300 s TTL = 1.5M keys at peak × ~200 bytes ≈ 300 MB; rate-limit counters roughly double it
SMS cost~₹70-90 lakh/month20M/day × 30 days × ~₹0.12-0.15 per domestic SMS ≈ ₹72-90 lakh/month; autofill (fewer resends) and cheaper channels cut this. International SMS cost 10-50× more, which is why pumping fraud matters
Brute-force odds5 in 1,000,000 per OTP6 digits = 10^6 codes; 5 attempts → 0.0005%. Without per-phone limits, an attacker cycling new OTPs gets 5 guesses each time, so resend limits matter too

3 API

EndpointWhat 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 lifetime

Hash 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 countersRedis
rl: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 / ClickHouse
request_idtenantpurposephone_hashcountryoperatorchannelprovidersent_atdelivered_atverified_atattemptscostrisk_score

Append-only events for funnel analytics, fraud review and disputes; no plaintext codes, phone stored hashed or encrypted.

provider routing tablePostgres + in-memory
countryoperatorchannel → ordered providersweightscostlive success rate

Weights updated every minute from delivery receipts.

templatesPostgres
tenantpurposelocalechannelbodydlt_template_idsender_id

Body must match the DLT-registered template exactly, including the autofill hash line.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALsend / verifyscore requeststore HMAC, limitspepper (cached)send jobroute by operatorfallbackdelivery receiptssend/verify eventsApps (login, payments)API gateway + WAFDLR webhook handlerOTP serviceRisk / abuse checksOTP send queue(priority)Channel sendersAudit + funnel storeRedis (OTP, limits)KMS (HMAC key)SMS aggregators (2-3)WhatsApp / voice
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Login OTP over SMS

  1. 1App calls /otp/send with phone, purpose=login, device_id and its 11-character SMS Retriever app hash
  2. 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
  3. 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
  4. 4It enqueues a send job on the critical lane and returns request_id and resend_after=30 s in ~20 ms
  5. 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
  6. 6Android's SMS Retriever reads the message and autofills; the app calls /otp/verify with request_id and code
  7. 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

  1. 1No delivery receipt arrives within ~15 s, or the receipt says failed
  2. 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
  3. 3On the second failure it offers WhatsApp (authentication template) or a voice call reading the code
  4. 4Each attempt is logged; per-provider failure rates update routing weights for everyone

SMS pumping attack

  1. 1Bots call /otp/send for thousands of numbers in a premium-rate international range
  2. 2Per-prefix velocity and the 'sent but never verified' ratio for that prefix spike within minutes
  3. 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

Q1How would you confirm a ₹50,000 payment so the OTP is tied to that amount and merchant?
Q2How would you design 'silent' phone verification without an SMS?
Q3How would you detect a SIM swap before sending a login OTP?
Q4What changes for email OTPs?
Q5How do you test OTP delivery quality across operators continuously?
Q6How would you run this across two regions?

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.

Start free