Cruxly

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 lesson

Prompt: 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

keyspace: 1,000,000 codes each square = 100,000 codes attempts allowed before lockout: 5 Without rate limiting: unlimited attempts → keyspace is exhausted in well under a second With 5 attempts + 5min expiry: attacker gets 5 guesses out of 1,000,000 — a 0.0005% success chance per code, before it's useless regardless of guesses left
The keyspace never gets bigger; what makes the design safe is bounding the number of guesses an attacker can make against it before the code stops being valid at all.

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

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons