- OTP login is a rate-limiting and fraud problem disguised as a feature: hashed single-use codes, attempt caps, resend cooldowns, per-phone/IP/device limits, multiple SMS providers.
- Use short-lived JWT access tokens (validated locally everywhere) plus server-side refresh tokens with rotation and reuse detection. You get stateless scale and real revocation.
- Revocation = short TTLs + revoke refresh tokens + a small denylist or "valid after" timestamp checked at the gateway.
- SSO = a central OpenID Connect identity provider with its own session; apps use the authorization code flow with PKCE.
- MFA and step-up auth (TOTP, passkeys) protect sensitive actions, because SMS alone is weak against SIM swap.
Every product has a login screen, so "design authentication" sounds easy. In interviews it is a sharp question: it tests whether you understand the difference between proving identity and carrying it around, why tokens are hard to revoke, what can go wrong with OTPs at Indian scale (where phone-number login is the norm for apps like Swiggy, Zomato, PhonePe, Dream11 and Zepto), and how SSO actually works rather than "we use OAuth".
I will design one system that covers a consumer app (phone OTP, mobile and web) and enterprise-style SSO across multiple internal apps, since interviewers often combine the two. For the protocol background, OAuth 2.0, OIDC and JWT explained is a good companion.
Scope and Requirements
Functional
- Sign up / log in with phone + OTP (primary), optionally email + password and "Sign in with Google".
- Stay logged in on mobile for weeks; web sessions with sensible expiry.
- Logout, logout from all devices, and a "your devices" list.
- SSO: one login works across the company's apps (consumer app, seller portal, support tool), and internal tools via corporate identity.
- MFA for sensitive accounts and step-up authentication for sensitive actions.
- Admin: lock accounts, force logout, audit login history.
Non-functional
- Token validation on every API request must add near-zero latency.
- Login availability is critical; an auth outage is a full outage.
- Resistant to brute force, credential stuffing, OTP abuse and token theft.
- Revocation effective within seconds to a few minutes.
- Compliant with local norms: in India, commercial SMS must use DLT-registered sender IDs and templates under TRAI rules, which affects OTP delivery setup.
Estimates
- 100M registered users, 30M DAU.
- Most users stay logged in. Assume 3M OTP logins/day (new devices, reinstalls, expired sessions) → ~35 OTP sends/s average, ~500/s at peak (sale launches, match start, marketing pushes).
- OTP SMS cost is real money: at even a fraction of a rupee per SMS, millions of sends a day add up, and fraud that inflates sends is a direct financial loss.
- Token validations: 30M DAU × ~200 API calls/day = 6B/day ≈ 70k/s average, ~300k/s at peak. This rules out a central database lookup per request and drives the JWT decision.
- Refresh calls: one per access token lifetime per active device. With 15-minute access tokens and ~5M concurrently active devices at peak, ~5,500 refreshes/s. Comfortable for a Redis-backed or sharded-DB token store.
Architecture
The auth service is both the login backend for first-party apps and an OpenID Connect provider for SSO. Everything else in the company only needs to validate tokens it issues, which is a local signature check.
Deep Dive 1: Phone OTP Login
The flow
POST /auth/otp/start {phone, device_id}: validate number format, run rate limits and risk checks, generate a 6-digit code with a CSPRNG, store its hash with a 5-minute TTL, send via SMS. Respond the same way whether or not the number is registered.- Android apps can auto-read the code with the SMS Retriever API (the message includes an app hash), so users do not type it; iOS offers one-time-code autofill from Messages.
POST /auth/otp/verify {phone, code, device_id}: increment the attempt counter, compare hashes in constant time, delete the code on success (single use), create or fetch the user, register the device, and issue tokens.
import hashlib
import hmac
import secrets
PEPPER = b"load-from-secrets-manager"
OTP_TTL_S, MAX_ATTEMPTS, RESEND_COOLDOWN_S = 300, 5, 30
def _digest(phone: str, otp: str) -> str:
return hmac.new(PEPPER, f"{phone}:{otp}".encode(), hashlib.sha256).hexdigest()
def send_otp(r, phone: str, sms) -> str:
if not r.set(f"otp:cooldown:{phone}", 1, ex=RESEND_COOLDOWN_S, nx=True):
return "TOO_SOON"
if r.incr(f"otp:sends:{phone}") > 5: # per-phone hourly cap
return "LIMITED"
r.expire(f"otp:sends:{phone}", 3600)
otp = f"{secrets.randbelow(10**6):06d}" # CSPRNG, not random.randint
r.set(f"otp:code:{phone}", _digest(phone, otp), ex=OTP_TTL_S)
r.delete(f"otp:attempts:{phone}")
sms.send(phone, otp)
return "SENT"
def verify_otp(r, phone: str, otp: str) -> str:
stored = r.get(f"otp:code:{phone}")
if stored is None:
return "EXPIRED"
if r.incr(f"otp:attempts:{phone}") > MAX_ATTEMPTS:
r.delete(f"otp:code:{phone}") # burn the code
return "LOCKED"
r.expire(f"otp:attempts:{phone}", OTP_TTL_S)
if not hmac.compare_digest(stored, _digest(phone, otp)):
return "WRONG"
r.delete(f"otp:code:{phone}", f"otp:attempts:{phone}") # single use
return "OK"
(With redis-py, r.get returns bytes unless the client is created with decode_responses=True; use that, or encode the digest before comparing.) Why each piece exists:
- Hash, not plaintext: anyone with Redis read access (or a memory dump) cannot log in as users.
- Attempt cap: a 6-digit code has 1,000,000 possibilities; 5 attempts give an attacker a 0.0005% chance per code. Without a cap, brute force takes minutes.
- Cooldown and send caps: stop users (and attackers) from spamming sends.
- Single use + short TTL: a leaked old code is useless.
Rate limits in layers
| Key | Example limit | Stops |
|---|---|---|
| Phone number | 1 per 30 s, 5 per hour, 10 per day | Harassment (bombing a victim with OTPs), brute force on one account |
| Device id / app install | 10 numbers per day | One device cycling through many numbers |
| IP / subnet | Adaptive, higher for carrier NAT ranges | Scripted abuse from servers; mobile carriers share IPs, so be careful |
| Country prefix / number range | Spike detection | SMS pumping to premium-rate ranges |
| Global | Budget alarm | Runaway cost |
The algorithms are covered in design a rate limiter; Redis primitives in the Redis deep dive.
Delivery reliability
- Multiple SMS providers with health-based routing; fail over if delivery receipts drop or latency rises. In India, templates and sender ids must be registered on the DLT platform for each provider route.
- Channel fallback: WhatsApp OTP or a voice call after a failed SMS.
- Measure send-to-verify conversion and time-to-verify per provider and operator; that is your real SLA.
Deep Dive 2: Sessions vs JWT
| Server-side session (opaque id) | JWT access token | |
|---|---|---|
| What the client holds | Random id, e.g. 32 bytes | Signed claims (user, scopes, expiry), typically hundreds of bytes to a few KB |
| Validation | Lookup in session store per request | Verify signature locally with public key |
| Revocation | Delete the session: instant | Hard until expiry; needs denylist or short TTL |
| Microservices | Every service needs the session store, or the gateway translates | Any service can verify independently |
| Data freshness | Always current (roles changed? next request sees it) | Claims stale until token refresh |
| Failure mode | Session store down = everyone logged out | Signing key leak = attacker can mint tokens |
The hybrid most large systems use
- Access token: JWT, signed with an asymmetric key (RS256/ES256), 5 to 15 minutes lifetime, minimal claims:
sub,sid(session id),scope,iat,exp,jti,aud. Validated locally by the gateway and services. - Refresh token: opaque random string, stored hashed server-side, long-lived (days to weeks for mobile), bound to a device/session record, rotated on every use.
- The session record (user, device, created_at, last_seen, IP, status) is the unit you list in "your devices" and revoke on logout.
Where tokens live on the client
- Web: refresh token (or session id) in an
HttpOnly; Secure; SameSite=Lax/Strictcookie scoped to the auth path, so JavaScript cannot read it (XSS cannot steal it). Access token in memory. Add CSRF protection for cookie-authenticated endpoints. - Mobile: refresh token in the Android Keystore-backed storage / iOS Keychain; access token in memory.
- Never put long-lived tokens in
localStorage; any XSS bug leaks them.
Deep Dive 3: Refresh Tokens with Rotation and Reuse Detection
A stolen long-lived refresh token is the worst credential leak short of a password. Rotation limits the damage: every refresh returns a new refresh token and marks the old one used. If an old one is presented again, either the attacker or the real client is replaying it, and you cannot tell which, so you revoke the whole token family.
import hashlib
import secrets
import time
REFRESH_TTL_S = 30 * 24 * 3600
def _h(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest() # store only hashes
class RefreshTokens:
def __init__(self, db):
self.db = db # token_hash -> {user, family, used, exp}
def issue(self, user_id, family=None):
token = secrets.token_urlsafe(32)
self.db[_h(token)] = {"user": user_id, "family": family or secrets.token_hex(8),
"used": False, "exp": time.time() + REFRESH_TTL_S}
return token
def rotate(self, token):
rec = self.db.get(_h(token))
if rec is None or rec["exp"] < time.time():
raise PermissionError("invalid refresh token")
if rec["used"]:
# reuse of an already-rotated token: likely stolen. Kill the whole family.
for r in self.db.values():
if r["family"] == rec["family"]:
r["exp"] = 0
raise PermissionError("refresh token reuse detected; session revoked")
rec["used"] = True
return rec["user"], self.issue(rec["user"], rec["family"])
store = RefreshTokens({})
t1 = store.issue("u42")
user, t2 = store.rotate(t1) # normal rotation
store.rotate(t1) # replay -> PermissionError, family revoked
# store.rotate(t2) now also fails
In production, the family is the session id, the store is Redis or a sharded table with an index on family, and the "revoke family" is a single update on the session record rather than a scan. Two practical refinements:
- Concurrency grace window: two browser tabs may refresh simultaneously with the same token. Allow the just-rotated token to be reused for a few seconds and return the same new token, instead of nuking the session.
- Absolute lifetime: even with rotation, cap a session at, say, 90 days, after which the user re-authenticates.
Deep Dive 4: Token Revocation That Works
"Log out from all devices", "password changed", "account compromised", "employee left". The problem: up to 15 minutes of valid JWTs are still out there.
| Mechanism | How | Latency to take effect | Cost |
|---|---|---|---|
| Revoke refresh token / session | Mark session revoked; refresh fails | Up to access token TTL | Trivial |
| Short access TTL | 5 to 15 minutes | Bounded by TTL | More refresh traffic |
jti denylist | Redis set of revoked token ids with TTL = remaining token life; pushed to gateway memory | Seconds | Small: only revoked tokens are listed |
| Per-user "valid after" | user:42:tokens_valid_after = ts; reject tokens with iat earlier | Seconds | One cached lookup per user |
| Per-session status | Gateway checks sid against a revoked-sessions set | Seconds | Small set, cacheable |
A solid answer combines them: revoke the session (no more refreshes), add the user's "valid after" timestamp or the session id to a revocation set replicated to every gateway node (via Redis pub/sub or a stream), and let entries expire once all affected access tokens have expired. Because revocations are rare relative to requests, the set stays tiny and the check is an in-memory hash lookup. Where the gateway does this check is covered in API gateway design.
Deep Dive 5: Single Sign-On with OpenID Connect
SSO means the user authenticates once with an identity provider (IdP), and many apps (relying parties) trust that. The modern standard is OpenID Connect (OIDC) on top of OAuth 2.0; enterprises also use SAML 2.0, especially for older SaaS integrations.
Authorization code flow with PKCE
- App redirects the browser to
https://id.example.com/authorize?response_type=code&client_id=seller-portal&redirect_uri=…&scope=openid profile&state=…&nonce=…&code_challenge=…&code_challenge_method=S256. - IdP checks its own session cookie. No session: show login (OTP, password, Google, corporate IdP), then MFA if required, and set the IdP session cookie on
id.example.com. - IdP redirects back with a short-lived, single-use
codeand the originalstate(the app verifiesstateto prevent CSRF). - App's backend exchanges the code at
POST /tokenwith thecode_verifier(PKCE proves the same client started the flow; mandatory for mobile and SPA public clients). - IdP returns an ID token (JWT about the user:
iss,sub,aud,exp,nonce), an access token for APIs, and optionally a refresh token. - App validates the ID token: signature via the IdP's JWKS,
iss,audequals its client id, not expired,noncematches. Then it creates its own local session. - Second app: the same redirect happens, but the IdP session cookie exists, so the IdP immediately redirects back with a code. That is the "single" in single sign-on.
Design details interviewers like
- Discovery and JWKS: apps read
/.well-known/openid-configurationand cache the JWKS; keys rotate with overlapping validity and are identified bykid. - Federation: the company IdP can itself delegate to Google or to a corporate IdP (Azure AD/Entra ID, Okta) for employees, mapping external identities to internal user ids by verified email or immutable subject id.
- Single logout is hard: logging out of the IdP does not automatically end each app's local session. OIDC defines front-channel and back-channel logout so the IdP can notify apps; apps must honour it and keep their local sessions short.
- Consent and scopes for third-party clients; first-party apps can skip consent screens.
- Do not use the implicit flow (tokens in the URL fragment); current best practice for browser and mobile apps is authorization code with PKCE.
MFA and Step-Up Authentication
Phone OTP is "something you have" (the SIM), but it is vulnerable to SIM swap fraud and phishing. For higher-risk accounts and actions, add factors:
- TOTP (authenticator apps, RFC 6238): a shared secret plus the current 30-second time step through HMAC.
- Passkeys / WebAuthn: phishing-resistant public-key credentials bound to the site's origin; the strongest practical option for consumers today.
- In-app approval on an already-trusted device ("Is this you logging in from Pune?").
- Device binding: a key pair created on first login, stored in secure hardware; later requests are signed, so stolen tokens alone are not enough. Indian payment apps commonly combine device binding with the SIM/number for exactly this reason.
import hashlib
import hmac
import struct
import time
def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
mac = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
offset = mac[-1] & 0x0F # dynamic truncation
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % 10 ** digits).zfill(digits)
def totp(secret: bytes, at: float = None, step: int = 30, digits: int = 6) -> str:
t = time.time() if at is None else at
return hotp(secret, int(t // step), digits)
def verify_totp(secret: bytes, code: str, at: float = None, window: int = 1) -> bool:
t = time.time() if at is None else at
return any(hmac.compare_digest(totp(secret, t + i * 30), code)
for i in range(-window, window + 1)) # tolerate small clock skew
secret = b"12345678901234567890" # RFC 6238 test key (SHA-1)
print(totp(secret, at=59, digits=8)) # 94287082
print(totp(secret, at=1111111109, digits=8)) # 07081804
Store TOTP secrets encrypted (envelope encryption with a KMS), and record the last accepted time step per user so the same code cannot be replayed within its window.
Step-up authentication
Instead of MFA on every login (friction), require a fresh strong factor for sensitive actions: changing the phone number, adding a payout bank account, large withdrawals, viewing full card details, admin operations. Implement it with an auth_time and acr/amr claim in the token; the API rejects the request with "step-up required" unless the token shows a recent strong authentication. This pattern is essential in wallet and payment systems.
Security Hardening Checklist
- Passwords (if supported): hash with Argon2id, scrypt or bcrypt with per-user salt; never fast hashes like SHA-256 alone. Check against breached-password lists.
- Credential stuffing: rate limit per account and per IP, detect many accounts tried from one source, bot detection, and lock or challenge rather than permanently lock (which enables denial of service on victims).
- No user enumeration: identical responses and timings for "unknown phone/email" and "wrong code".
- Signing keys in a KMS/HSM; rotate regularly; publish new keys in JWKS before using them; keep old keys until their tokens expire.
- Validate JWTs strictly: pin the expected algorithm (never accept
alg: noneor algorithm switching), checkiss,aud,exp,nbf. - Risk engine: new device, new city, impossible travel, emulator, rooted device → require extra verification.
- SIM swap signals: some telecom operators expose APIs for recent SIM change; treat a recent swap as high risk for sensitive actions.
- Audit log of every login, refresh failure, revocation and MFA change, streamed to a tamper-evident store and used for user-facing "recent activity".
Scaling and Availability
- Hot path is local: 300k token validations/s happen in gateway memory (signature check + revocation set lookup). The auth service is only on the path for logins and refreshes.
- Auth service is stateless behind a load balancer; state lives in Redis (OTP codes, rate counters, revocation sets) and a replicated SQL database (users, devices, sessions, MFA).
- Multi-region: users and sessions replicated across regions; OTP state can stay regional (a 5-minute code does not need global replication if requests are routed consistently).
- Graceful degradation: if the auth service is down, existing access tokens still work until expiry, and clients retry refresh with backoff. If Redis is down, decide: fail closed for OTP sends (to prevent abuse) and fail open for revocation checks only if you accept the risk.
- Thundering herd at expiry: add jitter to refresh timing on clients so millions of tokens issued during a launch do not all refresh at the same second.
The Interview Angle
Questions you should be ready for
- "Why not just use JWTs with a 30-day expiry?" Because you cannot revoke them, and a stolen token is valid for a month. Short access + rotated refresh.
- "Why not store sessions in the database and look them up every request?" Fine at small scale; at 300k req/s across 50 services it is a central bottleneck and a single point of failure. Show the numbers.
- "How do you prevent OTP brute force?" Attempt cap per code, codes hashed and single-use, short TTL, per-phone and per-device limits.
- "A user reports their account was hijacked. What do you do?" Revoke all sessions (valid-after timestamp), force re-auth with step-up, review audit logs, check for changed phone/email/payout details, notify the user.
- "How does a second app log the user in without asking again?" IdP session cookie + authorization code flow.
- "What is in your JWT?" Minimal claims; no PII beyond what services need; remember it is only signed, not encrypted, so anyone holding it can read it.
Common mistakes
- Storing OTPs or refresh tokens in plaintext.
- No attempt limit on OTP verification.
- Long-lived JWTs in
localStorage. - Validating tokens by calling the auth service on every request.
- Forgetting logout-all-devices and revocation entirely.
- Trusting client-sent user id headers past the gateway.
- Treating SMS OTP as strong enough for high-risk actions.
For REST-side details such as status codes for auth failures (401 vs 403) and header conventions, see REST API interview questions.
Frequently Asked Questions
Prepare for Security-Heavy System Design Rounds
Prepflix's system design course covers auth, SSO, gateways and payments the way fintech and product-company panels test them, with mock interviews and detailed feedback.
Explore the Prepflix Course →