How do you implement TOTP-based multi-factor authentication from scratch?
TOTP turns a shared secret and the current time into a rotating six-digit code using HMAC — understanding HOTP first makes every design detail obvious.
LEARN BY DOINGPut your intuition to work3 steps
How do you implement TOTP-based multi-factor authentication from scratch?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Start with HOTP — TOTP is just HOTP with a different counter
Before TOTP existed, RFC 4226 defined HOTP: an HMAC-based one-time password derived from a shared secret and a counter that increments by one every time a code is generated (think: a hardware token where pressing the button both generates a code and advances the counter). The server keeps its own copy of the counter and compares.
The core operation is simple: HMAC the counter with the secret, then take 4 bytes out of the resulting hash at a position chosen by the hash's own last nibble ("dynamic truncation"), mask off the sign bit, and reduce modulo 10^digits to get a fixed-length decimal code. The dynamic-truncation step is what makes the algorithm resistant to a specific class of attack where an attacker who could choose the truncation offset might bias the output distribution.
#From HOTP to TOTP: the counter becomes time
TOTP (RFC 6238) makes exactly one change: instead of an explicit counter that both parties track and increment, the counter is derived from the current Unix timestamp divided by a fixed time step (30 seconds is the near-universal default): counter = floor(unix_time / time_step). Both the authenticator app and the server compute the same counter independently as long as their clocks agree closely enough — no state needs to be synchronized between requests, which is exactly why TOTP works offline in an app that never talks to your server.
#A real implementation
import hashlibimport hmacimport structimport timeimport base64def hotp(secret: bytes, counter: int, digits: int = 6) -> str:"""RFC 4226 HOTP: HMAC-SHA1 over an 8-byte big-endian counter,truncated to `digits` decimal digits."""counter_bytes = struct.pack(">Q", counter) # 8 bytes, big-endianmac = hmac.new(secret, counter_bytes, hashlib.sha1).digest()# Dynamic truncation (RFC 4226 section 5.3)offset = mac[-1] & 0x0Ftruncated = mac[offset:offset + 4]code_int = struct.unpack(">I", truncated)[0] & 0x7FFFFFFFreturn str(code_int % (10 ** digits)).zfill(digits)def totp(secret: bytes, time_step: int = 30, digits: int = 6, at: float = None) -> str:"""RFC 6238 TOTP: HOTP with the counter derived from wall-clock time."""timestamp = time.time() if at is None else atcounter = int(timestamp // time_step)return hotp(secret, counter, digits)def verify_totp(secret: bytes, submitted_code: str, time_step: int = 30,digits: int = 6, window: int = 1) -> bool:"""Accepts a code from the current time step or `window` steps oneither side, to tolerate clock drift and human latency typing it in."""current_counter = int(time.time() // time_step)for offset in range(-window, window + 1):candidate = hotp(secret, current_counter + offset, digits)if hmac.compare_digest(candidate, submitted_code):return Truereturn Falsedef generate_base32_secret(num_bytes: int = 20) -> str:"""160-bit secret, the RFC 4226 recommended length, encoded fordisplay/QR provisioning."""import osraw = os.urandom(num_bytes)return base64.b32encode(raw).decode("utf-8")def provisioning_uri(secret_b32: str, account_name: str, issuer: str) -> str:"""otpauth:// URI consumable by Google Authenticator, Authy, etc.Encoded as a QR code by the caller."""from urllib.parse import quotelabel = quote(f"{issuer}:{account_name}")return (f"otpauth://totp/{label}"f"?secret={secret_b32}&issuer={quote(issuer)}"f"&algorithm=SHA1&digits=6&period=30")
Wiring it together for enrollment and login:
secret_b32 = generate_base32_secret() # store this per user, encrypted at restsecret_bytes = base64.b32decode(secret_b32)uri = provisioning_uri(secret_b32, account_name="jane@example.com", issuer="Acme")# render `uri` as a QR code for the user to scan into their authenticator app# ... later, at login:code_from_app = "482913"is_valid = verify_totp(secret_bytes, code_from_app)
Note that the secret stored for HMAC operations must be the raw bytes, decoded from base32 — the base32 text is only a transport/display encoding so the secret survives being typed by hand or embedded in a QR code URI.
#Why verification checks a window, not just one code
verify_totp above checks the current time step plus one step on either side (window=1), meaning it accepts a code that was valid up to 30 seconds ago or 30 seconds in the future. Two independent things push toward this: a user's phone and the server's clock are never perfectly synchronized (clock drift of even a few seconds is common), and there's real latency between the app displaying a code and the user finishing typing it. A window of exactly zero produces a maddening trickle of "invalid code" complaints from users doing everything right; too wide a window meaningfully increases the guessable attack surface, since each accepted step is one more 6-digit code (1 in a million odds) an attacker could get lucky with.
#Constant-time comparison matters here too
hmac.compare_digest is used to compare the computed and submitted codes instead of ==, for the same reason it matters in password verification: a naive string comparison that returns as soon as it finds a mismatched character leaks timing information about how many leading digits were correct, which — over enough attempts — is a real (if slow) side channel for guessing the true code.
#Enrollment gotchas worth knowing
- Never regenerate the secret silently. If the user re-scans a QR code, the old code stops working immediately; enrollment should show the secret exactly once and require the user to confirm a generated code before MFA is actually turned on, so a wrong scan doesn't lock them out of an account they never finished protecting.
- Provide backup codes. A user who loses their phone with no counter-based fallback is locked out permanently; single-use backup codes, generated at enrollment and invalidated on use, are the standard escape hatch.
- Store the secret encrypted, not merely "in a column with the rest of user data" — it's functionally as sensitive as the password itself, since anyone who reads it can generate valid codes indefinitely.