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