Cruxly › System design › Design Exercises
OTP / Two-Factor Authentication
a six-digit code is a tiny search space — the design has to survive someone guessing
Open the interactive lessonPrompt: design a one-time-password (OTP) two-factor authentication system — a user enters a 6-digit code sent via SMS or generated by an authenticator app, in addition to their password, to log in.
40 minutes first. The interesting part isn't sending an SMS. It's that a 6-digit code only has a million possible values, and the whole design has to survive that fact.
Applying the four moves: Scope — a 6-digit second factor via SMS or authenticator app, alongside a password; account recovery out (and flagged as the weakest link). Estimate — the keyspace is the estimate: a million possible codes means an unthrottled attacker wins quickly, so attempt limits are a direct consequence of that number, not a nice-to-have. Sketch — TOTP derived from a shared secret and the current time, plus a rate-limited verification endpoint. Deep dive — survive the small keyspace: per-account attempt limits with lockout, replay protection so a used code can't be reused inside its window, clock-drift tolerance, and naming SMS interception (SIM swap) as a real weakness.
The keyspace problem, stated precisely
Scope. In scope: OTP generation and delivery (SMS and authenticator-app / TOTP variants), verification with rate limiting and expiry, account lockout policy. Out of scope: the SMS gateway's own delivery reliability (a third-party integration concern), password-based primary authentication itself.
A 6-digit numeric code has exactly 1,000,000 possible values. Compare this to a password or an API key, which typically has a keyspace of astronomical size specifically so brute-forcing it is computationally infeasible — a 6-digit OTP is deliberately nowhere near that, because it has to be short enough for a human to read and type. This means the OTP system's security cannot come from keyspace size at all — it has to come entirely from limiting how many guesses an attacker gets before the code expires or the account locks. Stating this precisely, with the actual number, is a stronger opening than a vague "OTPs need rate limiting."
Rate limiting and expiry: doing the job keyspace size can't
Two independent limits do the actual security work: a short expiry window (typically 5-10 minutes) and a small cap on verification attempts per code (typically 3-5) before the code is invalidated outright, not just before the user is asked to try again. Both limits matter together — expiry alone still allows unlimited guesses within the window; attempt-capping alone (without expiry) still allows an attacker unlimited time to social-engineer or otherwise obtain a valid code before it's used. The attempt cap must invalidate the code itself, not just lock the user's IP or session — otherwise an attacker simply requests a fresh session and gets a fresh set of attempts against the same still-valid code, defeating the limit's purpose entirely.
Rate limiting the generation endpoint matters just as much as limiting verification attempts — without it, an attacker (or a bug in a client retry loop) can trigger unlimited SMS sends to a phone number that isn't even theirs, which is both a real-money cost (SMS delivery isn't free) and a harassment vector against the actual phone number's owner. A per-account and per-phone-number generation rate limit (the same token-bucket mechanism as this track's rate-limiter exercise) is a separate, equally necessary control from the verification-attempt cap.
SMS-delivered OTP versus TOTP (authenticator apps)
SMS delivery has a real, well-documented weakness: SIM-swapping — an attacker socially engineers a mobile carrier into porting the victim's phone number to a SIM the attacker controls, after which every SMS OTP for that number goes straight to the attacker. This is not a theoretical risk; it's a documented, repeatedly-exploited attack against high-value accounts, and naming it by name is what shows this isn't generic knowledge.
TOTP (Time-based One-Time Password, RFC 6238 — what authenticator apps like Google Authenticator implement) sidesteps SMS entirely. A shared secret is established once (via QR code) between the server and the user's authenticator app; both sides independently compute the same 6-digit code by hashing the shared secret together with the current time, rounded to a 30-second window (HMAC(secret, current_unix_time / 30), roughly). No network delivery is involved at verification time at all — the code is generated locally on the user's device — which eliminates SIM-swapping and SMS-interception as attack vectors entirely, at the cost of needing the shared secret to have been safely established during initial setup (itself a real, if smaller, security-sensitive moment) and needing the user's device clock to stay reasonably synchronized (real TOTP implementations accept the current window plus one window on either side, to tolerate a small amount of clock drift without weakening the 30-second granularity by much).
Failure modes and the honest edges
A user requesting a new code before their old one expires — does the old code become invalid immediately, or stay valid alongside the new one until its own expiry? Invalidating the previous code the moment a new one is issued is the safer default — otherwise an attacker who obtained an earlier code (via a still-open window) retains a live attack surface even after the legitimate user requested a fresh one.
Account lockout after repeated failed verification attempts is itself a denial-of-service vector — if lockout is purely based on failed attempts against one account, an attacker who doesn't even want to break in can lock a victim out of their own account just by repeatedly guessing wrong. A real design distinguishes a temporary cooldown (a few minutes) from a permanent lockout requiring identity re-verification, and may weight the lockout trigger by source IP/device fingerprint as well as account, so an attacker needs more than "guess wrong five times" to deny a legitimate user access.
OTP as a factor is only as strong as the channel establishing the 'something you have' claim in the first place — for SMS, that claim rests entirely on the carrier's SIM-provisioning security, which the OTP system itself has zero visibility into or control over. Naming this as a structural dependency the design can't fix, only mitigate by preferring TOTP where user adoption allows it, is the honest answer rather than presenting SMS OTP as unconditionally secure.
How to say it in the interview
"A six-digit code is only a million possibilities, which a script can brute-force in well under a second at any reasonable request rate, so the entire security model rests on rate limiting and expiry doing the job that keyspace size can't."
Checklist
- Stated the keyspace size precisely — one million possible six-digit codes
- Required both a short expiry window and a small verification-attempt cap, and explained why neither alone suffices
- Made the attempt cap invalidate the code itself, not just lock the session or IP making the attempts
- Rate-limited the OTP generation endpoint as a separate control from the verification-attempt cap
- Named SIM-swapping as a specific, documented weakness of SMS delivery, not a generic 'SMS is less secure' statement
- Explained TOTP as independently computable from a shared secret plus time, needing no delivery at verification time
- Named clock drift tolerance in TOTP verification and how real implementations handle it
- Invalidated a previous OTP the moment a new one is issued, rather than letting both remain valid
- Identified attempt-based account lockout as itself a denial-of-service vector against the legitimate user
- Named SMS OTP's dependency on carrier SIM-provisioning security as a structural limit the design can mitigate but not fix