Two-Factor Authentication System Low-Level Design

What is Two-Factor Authentication?

Two-factor authentication (2FA) requires users to verify their identity with a second factor beyond their password: something they have (TOTP app, hardware key) or something they receive (SMS code, email OTP). 2FA dramatically reduces account takeovers because stolen passwords alone are insufficient. TOTP (Time-based One-Time Password, RFC 6238) is the gold standard — used by Google Authenticator, Authy, and 1Password. SMS is convenient but phishable. WebAuthn/FIDO2 hardware keys are the most secure.

Requirements

  • Support TOTP (authenticator apps) and SMS-based OTP
  • Enrollment: generate a secret, display QR code, verify first code before enabling
  • Verification: on login, after password, prompt for 2FA code; verify within 30-second window
  • Recovery codes: generate 10 one-time backup codes at enrollment
  • Rate limiting: max 5 attempts per 10 minutes before lockout
  • Audit log every 2FA event (enabled, disabled, used, failed)

TOTP Protocol

TOTP is derived from HOTP (HMAC-based OTP):
  T = floor(current_unix_time / 30)   # 30-second window
  TOTP = HOTP(secret, T)
  HOTP = HMAC-SHA1(secret, T) → truncate to 6 digits

The secret is a random 20-byte value, base32-encoded for QR code display.
Both the server and the authenticator app compute TOTP independently using the
shared secret and the current time — no network call needed.

Data Model

UserTwoFactor(
    user_id     UUID PRIMARY KEY,
    method      ENUM(TOTP, SMS, DISABLED),
    totp_secret VARCHAR,        -- base32-encoded, encrypted at rest
    phone       VARCHAR,        -- for SMS method
    enabled     BOOL DEFAULT false,
    enrolled_at TIMESTAMPTZ,
    last_used_at TIMESTAMPTZ
)

TwoFactorRecoveryCode(
    code_id     UUID PRIMARY KEY,
    user_id     UUID NOT NULL,
    code_hash   VARCHAR NOT NULL,  -- bcrypt hash of recovery code
    used        BOOL DEFAULT false,
    used_at     TIMESTAMPTZ
)

TwoFactorAttempt(
    attempt_id  UUID PRIMARY KEY,
    user_id     UUID,
    success     BOOL,
    method      VARCHAR,
    ip_address  VARCHAR,
    created_at  TIMESTAMPTZ
)

Enrollment Flow

def enroll_totp(user_id):
    # 1. Generate secret
    secret = base64.b32encode(os.urandom(20)).decode()

    # 2. Store (not yet enabled)
    db.upsert(UserTwoFactor(user_id=user_id, method='TOTP',
                            totp_secret=encrypt(secret), enabled=False))

    # 3. Generate QR code URI
    uri = f'otpauth://totp/MyApp:{user_email}?secret={secret}&issuer=MyApp'
    qr_png = qrcode.make(uri)  # display to user

    # 4. Return QR + secret (user scans with authenticator app)
    return {'qr': qr_png, 'secret': secret, 'uri': uri}

def confirm_enrollment(user_id, code):
    user_2fa = db.get(UserTwoFactor, user_id)
    secret = decrypt(user_2fa.totp_secret)

    if verify_totp(secret, code):
        db.update(UserTwoFactor, user_id, enabled=True, enrolled_at=now())
        # Generate recovery codes
        codes = [secrets.token_hex(8) for _ in range(10)]
        for code in codes:
            db.insert(TwoFactorRecoveryCode(user_id=user_id,
                                            code_hash=bcrypt.hash(code)))
        return {'recovery_codes': codes}  # show once, never again
    raise InvalidCode()

Verification with Time Window

import hmac, hashlib, struct, time

def verify_totp(secret, code, window=1):
    # window=1 allows T-1, T, T+1 (compensates for clock skew)
    secret_bytes = base64.b32decode(secret)
    T = int(time.time() // 30)
    for delta in range(-window, window + 1):
        expected = generate_totp(secret_bytes, T + delta)
        if hmac.compare_digest(str(expected).zfill(6), str(code).zfill(6)):
            return True
    return False

def generate_totp(secret_bytes, T):
    msg = struct.pack('>Q', T)
    h = hmac.new(secret_bytes, msg, hashlib.sha1).digest()
    offset = h[-1] & 0x0f
    code = struct.unpack('>I', h[offset:offset+4])[0] & 0x7fffffff
    return code % 1000000

Rate Limiting and Lockout

def check_rate_limit(user_id, ip):
    key = f'2fa_attempts:{user_id}'
    attempts = redis.incr(key)
    if attempts == 1:
        redis.expire(key, 600)  # 10-minute window
    if attempts > 5:
        raise TooManyAttempts(retry_after=redis.ttl(key))

Key Design Decisions

  • TOTP window ±1 — compensates for clock drift between user device and server (up to 30 seconds)
  • Encrypt TOTP secret at rest — it’s a long-lived credential; treat it like a password
  • bcrypt for recovery codes — one-way hash; if DB is compromised, codes can’t be extracted
  • Disabled by default, require confirmation code before enabling — prevents enrolling with wrong secret
  • Rate limit per user + IP — user-level prevents enumeration; IP-level prevents distributed attacks

{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How does TOTP (Time-based One-Time Password) work?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”TOTP computes a 6-digit code using HMAC-SHA1 of a shared secret and the current 30-second time window: T = floor(unix_time / 30), code = HOTP(secret, T) truncated to 6 digits. Both the server and the authenticator app independently compute the same code — no network call is needed. The server accepts codes for T-1, T, and T+1 to compensate for clock drift (up to 30 seconds). The shared secret is generated at enrollment and encoded as base32 for display in a QR code.”}},{“@type”:”Question”,”name”:”Why is TOTP more secure than SMS-based 2FA?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”SMS 2FA is vulnerable to SIM-swapping (attacker convinces carrier to transfer your number), SS7 interception (telecom network attacks), and phishing (attacker redirects you to a fake site that captures your SMS code in real time). TOTP codes are generated locally on your device using a secret that never leaves the app — there is nothing to intercept in transit. TOTP codes are also time-limited to 30 seconds and single-use, making phishing harder (the attacker must relay the code within the window).”}},{“@type”:”Question”,”name”:”How do recovery codes work for 2FA?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Generate 10 random 8-byte (16-character hex) recovery codes at enrollment. Store bcrypt hashes of the codes in the DB — never store plaintext. Show the codes to the user exactly once (they must save them). When a user loses their authenticator app, they enter a recovery code at the 2FA prompt. The server bcrypt-compares the entered code against stored hashes; on match, mark the code used=true and allow login. Each code is single-use. Users should disable and re-enroll 2FA after using a recovery code.”}},{“@type”:”Question”,”name”:”How do you rate-limit 2FA verification attempts?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use Redis to count attempts per user within a rolling window: INCR 2fa_attempts:{user_id} with EXPIRE 600 (10 minutes). After 5 failed attempts, return 429 Too Many Requests with a Retry-After header. Reset the counter on a successful verification. Also track by IP address (INCR 2fa_attempts:{ip}) to catch distributed attacks where one attacker cycles through many user accounts from different IPs. Log all failed attempts to the audit log for security monitoring.”}},{“@type”:”Question”,”name”:”What is WebAuthn and how is it different from TOTP?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”WebAuthn (FIDO2) uses public-key cryptography instead of shared secrets. During enrollment, the user’s device generates a key pair; the public key is stored on the server, the private key stays on the device (in a hardware security module on the phone or a hardware key like YubiKey). During authentication, the server sends a challenge; the device signs it with the private key; the server verifies with the stored public key. WebAuthn is phishing-resistant (the key pair is bound to the origin domain) and doesn’t require typing codes — the strongest 2FA available.”}}]}

Two-factor authentication and account security design is discussed in Google system design interview questions.

Two-factor authentication and security system design is covered in Coinbase system design interview preparation.

Authentication and account security system design is discussed in Stripe system design interview guide.

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

Scroll to Top