How to Build TOTP-Based Two-Factor Authentication From Scratch in Python
Learn how the six-digit codes in Google Authenticator and Authy actually work by implementing TOTP from scratch in Python, verified against the official RFC test vectors, then wire it into a real...
Most “enter the 6-digit code from your authenticator app” login screens are running a 20-year-old, fully public algorithm called TOTP. There is no vendor secret sauce inside Google Authenticator, Authy, or 1Password: the entire scheme is two short internet standards, and you can implement it yourself in well under 100 lines of Python using nothing but the standard library.
Table Of Content
- What you will build, and why it matters
- Prerequisites
- Step 1: Set up your project
- Step 2: Implement HOTP from scratch
- Verify against the official RFC 4226 test vectors
- Step 3: Turn HOTP into TOTP with a time-based counter
- Verify against the official RFC 6238 test vectors
- Step 4: Cross-check your implementation against a real library
- Step 5: Generate a Base32 secret and a scannable QR code
- Gotcha: the algorithm and digits parameters are usually ignored
- Step 6: Handle clock drift with a validation window
- Step 7: Stop replay attacks
- Step 8: Build a complete signup and login flow with FastAPI
- Password hashing
- Enrollment: signup and confirmation
- Login and second-factor verification
- Backup codes and rate limiting
- Step 9: Test the whole flow end to end
- Signup and enrollment
- A real gotcha, discovered while testing: the enrollment code is already spent
- Replay protection in the live server
- Lockout after repeated failures
- A second real gotcha: this lockout blocks backup codes too
- Backup codes are single-use
- A wrong password never reaches the second factor at all
- Common mistakes and gotchas
- How to verify it all works end to end
- Next steps
This tutorial builds a real TOTP-based two-factor authentication (2FA) system from the ground up: the code-generation algorithm first, then a full signup and login flow with a QR-scannable enrollment screen, clock-drift tolerance, replay protection, backup codes, and rate limiting. Every function is checked against the official RFC test vectors and cross-verified against the widely used pyotp library, and every claim about how the algorithm behaves is demonstrated with real, captured output rather than just described.
What you will build, and why it matters
Two-factor authentication means proving your identity with two different categories of evidence: something you know (a password) and something you have (a phone, or a hardware key). A password alone only proves you know a string of characters, and strings of characters leak in breaches, get phished, and get reused across sites. Requiring a second factor means a stolen password is no longer enough to log in as someone else.
TOTP, short for Time-based One-Time Password, is the specific mechanism behind the six-digit codes that authenticator apps display. It is defined in two IETF documents:
- RFC 4226 defines HOTP (HMAC-based One-Time Password): turn a shared secret and a counter into a short numeric code using HMAC.
- RFC 6238 defines TOTP: use HOTP exactly as written, but replace the counter with a number derived from the current time. That is the entire difference between the two algorithms.
Because both are open standards with published test vectors, you do not have to trust a library’s word that it works. You can compute the numbers by hand (well, by script) and compare them against the values IETF itself published in 2005 and 2011.
Prerequisites
- Python 3.10 or later (this tutorial uses 3.13). Basic comfort with functions, dictionaries, and reading a stack trace.
- A terminal and a text editor. No prior cryptography background is assumed; every cryptographic primitive used here (HMAC, SHA-1) is explained as it comes up.
- Familiarity with making HTTP requests (curl or Python’s
requests) is helpful for the FastAPI section, but not required to follow along. - This tutorial hand-rolls the algorithm for teaching purposes. For a real production login system, use a vetted library (
pyotpfor Python, or a framework plugin likedjango-otp) instead of hand-rolled cryptographic code; understanding how the algorithm works underneath is still what lets you configure, debug, and audit those libraries correctly.
Step 1: Set up your project
mkdir totp-demo && cd totp-demo
python -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate
pip install pyotp "qrcode[pil]" fastapi uvicorn requests
Here is what each package is for: pyotp is a well-tested reference implementation you will use only to cross-check your own code, never as a replacement for it. qrcode[pil] generates the QR code image an authenticator app scans. fastapi and uvicorn build the demo login server. requests drives the end-to-end test client against that server.
Step 2: Implement HOTP from scratch
RFC 4226 Section 5.3 describes HOTP as three steps applied to a shared secret key K and a counter C:
- Compute
HS = HMAC-SHA-1(K, C), a 20-byte value. - Dynamic Truncation: use the low 4 bits of the last byte of
HSas an offset into the 20 bytes, read 4 bytes starting at that offset, and clear the top bit to get a 31-bit number. - Reduce that number modulo 10 to the power of the desired digit count, and zero-pad it to that many digits.
Step 2 is the part that trips people up on a first read. The “dynamic” in Dynamic Truncation means the 4-byte window you extract is not fixed at, say, bytes 0 to 3. It moves depending on the data itself (the last byte of the HMAC output picks where the window starts), which is deliberate: it stops an attacker from being able to reason about a fixed slice of the HMAC output in isolation.
import hashlib
import hmac
import struct
def hotp(secret: bytes, counter: int, digits: int = 6) -> str:
# RFC 4226 Section 5.3, Step 1: HS = HMAC-SHA-1(K, C)
counter_bytes = struct.pack(">Q", counter) # 8-byte big-endian counter
hs = hmac.new(secret, counter_bytes, hashlib.sha1).digest() # 20 bytes
# Step 2: Dynamic Truncation DT(HS) -> 31-bit string
offset = hs[-1] & 0x0F # low-order 4 bits of the last byte
truncated = hs[offset:offset + 4]
s_bits = struct.unpack(">I", truncated)[0] & 0x7FFFFFFF # clear top bit
# Step 3: D = Snum mod 10^Digit
d = s_bits % (10 ** digits)
return str(d).zfill(digits)
The counter has to be packed as an 8-byte big-endian integer because that is the exact byte layout both sides of an HOTP exchange agree to hash; if you packed it little-endian, or as 4 bytes instead of 8, you would get a completely different, non-interoperable HMAC input.
Verify against the official RFC 4226 test vectors
RFC 4226 Appendix D publishes the correct HOTP output for the ASCII secret "12345678901234567890" at counters 0 through 9. If your implementation cannot reproduce these exact numbers, something is wrong before you have written a single line involving time, QR codes, or a database.
if __name__ == "__main__":
secret = b"12345678901234567890"
expected = [
"755224", "287082", "359152", "969429", "338314",
"254676", "287922", "162583", "399871", "520489",
]
for counter, exp in enumerate(expected):
got = hotp(secret, counter)
print(f"counter={counter} expected={exp} got={got} [{'OK' if got == exp else 'MISMATCH'}]")
Running that produces:
counter=0 expected=755224 got=755224 [OK]
counter=1 expected=287082 got=287082 [OK]
counter=2 expected=359152 got=359152 [OK]
counter=3 expected=969429 got=969429 [OK]
counter=4 expected=338314 got=338314 [OK]
counter=5 expected=254676 got=254676 [OK]
counter=6 expected=287922 got=287922 [OK]
counter=7 expected=162583 got=162583 [OK]
counter=8 expected=399871 got=399871 [OK]
counter=9 expected=520489 got=520489 [OK]
All ten match exactly. That is a strong signal the byte-packing, hashing, truncation, and modulo steps are all correct, independent of anything related to time.
Step 3: Turn HOTP into TOTP with a time-based counter
RFC 6238 Section 4.2 defines TOTP as literally HOTP(K, T), where T replaces the counter C and is computed as:
T = floor((current Unix time - T0) / X)
X is the time step in seconds (the standard default is 30), and T0 is the Unix time to start counting from (the standard default is 0, the Unix epoch). In plain terms: take the current time, divide it into 30-second buckets counting from 1970, and use the bucket number as the HOTP counter. Every device that agrees on the shared secret and the current time will land in the same bucket and compute the same code, without ever exchanging a network message.
from step1_hotp import hotp
def totp(secret: bytes, for_time: int, digits: int = 6, period: int = 30, t0: int = 0) -> str:
# RFC 6238 Section 4.2: T = floor((current Unix time - T0) / X)
counter = (for_time - t0) // period
return hotp(secret, counter, digits=digits)
Verify against the official RFC 6238 test vectors
RFC 6238 Appendix B publishes SHA-1-mode, 8-digit TOTP values for the same secret used in RFC 4226, at six specific Unix timestamps. The RFC uses 8 digits in its published test table to make the math easier to double-check by hand; every authenticator app you have ever used defaults to 6 digits and a 30-second period, which is what the rest of this tutorial uses for anything user-facing.
if __name__ == "__main__":
secret = b"12345678901234567890"
vectors = {
59: "94287082",
1111111109: "07081804",
1111111111: "14050471",
1234567890: "89005924",
2000000000: "69279037",
}
for t, expected in vectors.items():
got = totp(secret, t, digits=8)
print(f"time={t:<12} expected={expected} got={got} [{'OK' if got == expected else 'MISMATCH'}]")
time=59 expected=94287082 got=94287082 [OK]
time=1111111109 expected=07081804 got=07081804 [OK]
time=1111111111 expected=14050471 got=14050471 [OK]
time=1234567890 expected=89005924 got=89005924 [OK]
time=2000000000 expected=69279037 got=69279037 [OK]
Five for five. Notice that timestamps 1111111109 and 1111111111 are only two seconds apart but land on different 30-second buckets (0x23523EC versus 0x23523ED in hex, one apart), which is why they produce completely different codes: TOTP does not care how close in real time two requests are, only which 30-second bucket they fall into.
Step 4: Cross-check your implementation against a real library
RFC test vectors prove your algorithm is correct for those specific historical timestamps and that one specific secret. Before trusting it for a real random secret at the current moment, cross-check it against pyotp, a widely deployed independent implementation, across several live time windows.
import base64
import secrets
import time
import pyotp
from step2_totp import totp
def generate_secret(num_bytes: int = 20) -> str:
# 20 random bytes -> Base32, matching the 160-bit secret RFC 4226 recommends
raw = secrets.token_bytes(num_bytes)
return base64.b32encode(raw).decode("ascii")
if __name__ == "__main__":
b32_secret = generate_secret()
raw_secret = base64.b32decode(b32_secret)
pyotp_gen = pyotp.TOTP(b32_secret) # defaults: SHA1, 6 digits, 30s period
now = int(time.time())
print("cross-checking 5 consecutive 30s windows:")
for i in range(5):
t = now + i * 30
ours = totp(raw_secret, t, digits=6, period=30)
theirs = pyotp_gen.at(t)
print(f" t={t} ours={ours} pyotp={theirs} {'OK' if ours == theirs else 'MISMATCH'}")
cross-checking 5 consecutive 30s windows:
t=1788173327 ours=559412 pyotp=559412 OK
t=1788173357 ours=442234 pyotp=442234 OK
t=1788173387 ours=989558 pyotp=989558 OK
t=1788173417 ours=950611 pyotp=950611 OK
t=1788173447 ours=586136 pyotp=586136 OK
A completely independent implementation, written by different people, agrees with yours byte for byte across every window, for a freshly generated random secret. That is as close to proof as you get without a formal specification checker.
RFC 4226 Section 4, requirement R6, is explicit about secret length: “The algorithm MUST use a strong shared secret. The length of the shared secret MUST be at least 128 bits. This document RECOMMENDs a shared secret length of 160 bits.” The generate_secret() function above uses secrets.token_bytes(20), 20 bytes, which is exactly 160 bits, the recommended size, generated from Python’s cryptographically secure random source rather than the general-purpose random module.
Step 5: Generate a Base32 secret and a scannable QR code
An authenticator app needs to receive the same secret bytes your server generated. The de facto standard for handing that off is a URI in the otpauth:// scheme, documented in Google’s Key URI Format reference, rendered as a QR code the user scans once during signup.
otpauth://totp/LABEL?secret=BASE32SECRET&issuer=IssuerName&algorithm=SHA1&digits=6&period=30
import urllib.parse
import qrcode
def build_otpauth_uri(secret_b32: str, account_name: str, issuer: str,
digits: int = 6, period: int = 30, algorithm: str = "SHA1") -> str:
label = urllib.parse.quote(f"{issuer}:{account_name}")
params = {
"secret": secret_b32,
"issuer": issuer,
"algorithm": algorithm,
"digits": str(digits),
"period": str(period),
}
query = urllib.parse.urlencode(params)
return f"otpauth://totp/{label}?{query}"
if __name__ == "__main__":
uri = build_otpauth_uri("KQL72WOU6EXBHRGOINC4ZM7D7GZ5FB5F", "amir", "SXZ Demo")
print(uri)
img = qrcode.make(uri)
img.save("otpauth_qr.png")
otpauth://totp/SXZ%20Demo%3Aamir?secret=KQL72WOU6EXBHRGOINC4ZM7D7GZ5FB5F&issuer=SXZ+Demo&algorithm=SHA1&digits=6&period=30
Parsing that URI back apart with urllib.parse the same way an authenticator app would confirms the Base32 secret survives the round trip through URL encoding unchanged, which matters because a single corrupted character in the secret produces a completely different, silently-wrong set of codes rather than an obvious error:
parsed = urllib.parse.urlparse(uri)
qs = urllib.parse.parse_qs(parsed.query)
print("secret from URI:", qs["secret"][0])
assert qs["secret"][0] == "KQL72WOU6EXBHRGOINC4ZM7D7GZ5FB5F"
print("round-trip OK: secret survives URI encode/decode unchanged")
secret from URI: KQL72WOU6EXBHRGOINC4ZM7D7GZ5FB5F
round-trip OK: secret survives URI encode/decode unchanged
Gotcha: the algorithm and digits parameters are usually ignored
The Key URI reference is explicit about this: “Currently, the algorithm parameter is ignored by the Google Authenticator implementations,” and on Android and BlackBerry, “the digits parameter is ignored” too. In practice this means you should not rely on setting algorithm=SHA256 or digits=8 in the URI to actually change what a real phone app does; the safe, portable choice for anything meant to work with mainstream authenticator apps is the default: SHA-1, 6 digits, a 30-second period. If you need 8-digit or SHA-256 codes for some other reason, you will likely need an app that explicitly supports it, not just a URI parameter.
Step 6: Handle clock drift with a validation window
A code the user typed was generated a few seconds ago on their phone, then travelled over the network, then got processed by your server, all of which takes non-zero time. If your server only accepts a code matching the exact current 30-second bucket, a perfectly legitimate login can fail simply because the request landed one bucket late.
import time
from step2_totp import totp
def verify_naive(secret: bytes, code: str, digits: int = 6, period: int = 30) -> bool:
"""Only accepts a code matching the exact current time step."""
now = int(time.time())
return totp(secret, now, digits=digits, period=period) == code
def verify_with_window(secret: bytes, code: str, digits: int = 6, period: int = 30,
valid_window: int = 1):
"""Accepts a code from valid_window steps before/after now.
Returns the matched step index (useful for replay protection) or None.
"""
now = int(time.time())
current_step = now // period
for offset in range(-valid_window, valid_window + 1):
step = current_step + offset
candidate = totp(secret, step * period, digits=digits, period=period)
if candidate == code:
return step
return None
Here is that clock-drift scenario reproduced for real: generate a code for exactly one 30-second step in the past, then check it both ways. It is worth generating that “one step ago” code from the step number itself, current_step - 1, rather than by subtracting 45 raw seconds from the clock: whether “45 seconds ago” lands one step back or two depends entirely on where now happens to fall inside its own current 30-second window, which is effectively random. Step arithmetic sidesteps that ambiguity completely.
secret = b"a-fake-shared-secret-for-testing"
now = int(time.time())
period = 30
current_step = now // period
one_step_ago_code = totp(secret, (current_step - 1) * period, digits=6, period=period)
print("naive verify (exact step only):", verify_naive(secret, one_step_ago_code))
print("windowed verify (+/-1 step): ", verify_with_window(secret, one_step_ago_code, valid_window=1))
three_steps_ago_code = totp(secret, (current_step - 3) * period, digits=6, period=period)
print("3-steps-old code with +/-1 window:", verify_with_window(secret, three_steps_ago_code, valid_window=1))
naive verify (exact step only): False
windowed verify (+/-1 step): 59605814
3-steps-old code with +/-1 window: None
The naive exact-match check rejects a perfectly valid, only-slightly-late code. The windowed check accepts it and returns the exact step number it matched, which is what a real verifier would go on to store as last_used_step. It still correctly rejects a code that is three steps (90 seconds) old, because RFC 6238 Section 5.2 explicitly warns that a window which is too generous (its own example is 10 minutes) makes the codes practically useless as a time-limited secret, and Section 6 separately recommends the validator be set with a specific, bounded limit on how many time steps a prover can be out of sync before being rejected. A valid_window of 1 (accept one step early or late, roughly plus or minus 30 seconds beyond the current step) is the standard, widely used compromise.
This is also a useful lesson on its own: when you write tests or demos involving a rolling time window, generate your “N steps ago” fixtures from the step counter, not from raw wall-clock seconds. The first version of this exact demo used now - 45 and now - 90, and it failed intermittently: correct whenever now happened to be more than 15 seconds into its current window, and wrong whenever it was not, since subtracting 45 raw seconds from a timestamp only 2 seconds into its window lands two steps back, not one. Reconstructing and re-running this exact section while writing this tutorial is what caught it.
Step 7: Stop replay attacks
RFC 6238 Section 5.2 states this requirement in exactly these words: “Note that a prover may send the same OTP inside a given time-step window multiple times to a verifier. The verifier MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP, which ensures one-time only use of an OTP.” The windowed verifier from Step 6 does not do this yet: nothing stops the exact same six digits from being submitted, and accepted, over and over for the full 30 to 90 seconds they remain valid. If an attacker intercepts one code (over someone’s shoulder, through a compromised proxy, from a phishing page that relays it in real time), they have a window to use it too.
Here is a verifier with no memory at all, for contrast, followed by the fix: remember the last step number that was successfully used for a given account, and reject any step at or before it.
class UserWithoutReplayProtection:
def __init__(self, secret: bytes):
self.secret = secret
def verify(self, code: str) -> bool:
step = verify_with_window(self.secret, code, valid_window=1)
return step is not None
class UserWithReplayProtection:
def __init__(self, secret: bytes):
self.secret = secret
self.last_used_step = None # persisted per-user in a real system
def verify(self, code: str) -> bool:
step = verify_with_window(self.secret, code, valid_window=1)
if step is None:
return False
if self.last_used_step is not None and step <= self.last_used_step:
return False # this step (or an older one) was already spent
self.last_used_step = step
return True
Generate one real code, then submit that same code three times against each verifier:
secret = b"another-fake-shared-secret-value"
now = int(time.time())
code = totp(secret, now, digits=6, period=30)
print(f"real code for this instant: {code}")
print("\n--- without replay protection ---")
victim = UserWithoutReplayProtection(secret)
print("first submission :", victim.verify(code))
print("attacker replay 1:", victim.verify(code))
print("attacker replay 2:", victim.verify(code))
print("\n--- with replay protection ---")
protected = UserWithReplayProtection(secret)
print("first submission :", protected.verify(code))
print("attacker replay 1:", protected.verify(code))
print("attacker replay 2:", protected.verify(code))
real code for this instant: 467561
--- without replay protection ---
first submission : True
attacker replay 1: True
attacker replay 2: True
--- with replay protection ---
first submission : True
attacker replay 1: False
attacker replay 2: False
Without tracking the last used step, the identical code succeeds three times in a row. With it, only the first submission counts, and every subsequent replay of that same code is rejected, exactly as the RFC mandates. Note that last_used_step has to live on the user record itself, not on a single login session: RFC 4226 Section 7.3 separately requires that any throttling or lockout mechanism “MUST be across login sessions to prevent attacks based on multiple parallel guessing techniques,” and the same logic applies to replay tracking, otherwise an attacker could just start a second login attempt to get a fresh, unspent replay window.
Step 8: Build a complete signup and login flow with FastAPI
With the core algorithm proven correct, wire it into an actual authentication flow: password hashing, TOTP enrollment with a QR code, login gated behind a second factor, single-use backup codes for when the phone is unavailable, and rate limiting against brute-force guessing.
Password hashing
import hashlib
import os
import secrets
def hash_password(password: str, salt: bytes | None = None) -> str:
salt = salt or os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000)
return f"{salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
salt_hex, digest_hex = stored.split("$")
salt = bytes.fromhex(salt_hex)
expected = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000)
return secrets.compare_digest(expected.hex(), digest_hex)
This uses PBKDF2-HMAC-SHA256 with 600,000 iterations, the minimum work factor OWASP’s Password Storage Cheat Sheet specifies for PBKDF2 (OWASP’s first choice for new systems is actually Argon2id; it lists PBKDF2 mainly for cases needing FIPS-140 compliance). This is purely so the demo has a realistic password step; TOTP itself does not depend on how passwords are hashed, but a real login flow needs both factors implemented soundly.
Enrollment: signup and confirmation
@app.post("/signup")
def signup(req: SignupRequest):
if req.username in USERS:
raise HTTPException(400, "username already exists")
raw_secret = secrets.token_bytes(20)
user = User(username=req.username, password_hash=hash_password(req.password),
totp_secret=raw_secret)
USERS[req.username] = user
secret_b32 = base64.b32encode(raw_secret).decode("ascii")
uri = build_otpauth_uri(secret_b32, req.username, "SXZ Demo")
return {"secret_base32": secret_b32, "otpauth_uri": uri}
@app.post("/confirm-2fa-enrollment")
def confirm_enrollment(req: ConfirmEnrollRequest):
user = USERS.get(req.username)
if not user or user.totp_secret is None:
raise HTTPException(404, "no pending enrollment")
step = _verify_totp_window(user.totp_secret, req.code)
if step is None:
raise HTTPException(400, "invalid code, scan the QR code again")
user.totp_enabled = True
user.last_used_step = step
codes = [secrets.token_hex(4) for _ in range(8)]
user.backup_code_hashes = {hash_backup_code(c) for c in codes}
return {"enabled": True, "backup_codes": codes}
The confirmation step matters more than it looks: signup alone only proves the server generated a secret. Requiring the user to type back a valid code before flipping totp_enabled to True proves the QR code was actually scanned correctly and the app is producing matching codes right now, before you lock the account behind it. Skip this step and a failed or mistyped QR scan can permanently lock a user out of their own new account.
Login and second-factor verification
@app.post("/login")
def login(req: LoginRequest):
user = USERS.get(req.username)
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(401, "invalid username or password")
if not user.totp_enabled:
return {"status": "logged_in", "username": user.username}
temp_token = secrets.token_urlsafe(24)
PENDING_2FA[temp_token] = user.username
return {"status": "requires_2fa", "temp_token": temp_token}
The password check happens completely on its own, first, and returns identical-looking failures whether the username does not exist or the password is wrong. Only after the password is verified does a short-lived temp_token get issued, scoped to nothing more than “this specific person just proved they know the password for this account and still needs to prove the second factor.” That token, not the username, is what the next request has to present.
Backup codes and rate limiting
@app.post("/verify-2fa")
def verify_2fa(req: Verify2FARequest):
username = PENDING_2FA.get(req.temp_token)
if username is None:
raise HTTPException(401, "invalid or expired session")
user = USERS[username]
now = time.time()
if now < user.locked_until:
raise HTTPException(429, f"too many attempts, try again in "
f"{int(user.locked_until - now)}s")
# Fallback: a single-use backup code
code_hash = hash_backup_code(req.code)
if code_hash in user.backup_code_hashes:
user.backup_code_hashes.discard(code_hash)
del PENDING_2FA[req.temp_token]
user.failed_attempts = 0
return {"status": "logged_in", "username": username, "via": "backup_code"}
step = _verify_totp_window(user.totp_secret, req.code)
if step is None or (user.last_used_step is not None and step <= user.last_used_step):
user.failed_attempts += 1
if user.failed_attempts >= MAX_ATTEMPTS:
user.locked_until = now + LOCKOUT_SECONDS
user.failed_attempts = 0
raise HTTPException(401, "invalid or already-used code")
user.last_used_step = step
user.failed_attempts = 0
del PENDING_2FA[req.temp_token]
return {"status": "logged_in", "username": username, "via": "totp"}
Backup codes exist for the day the user’s phone is lost, dead, or simply not in hand: eight single-use, high-entropy hex codes, hashed with SHA-256 before storage the same way you would hash anything else you never want sitting around in plaintext, each one deleted from the set the moment it is redeemed. The rate limiting follows RFC 4226 Section 7.3’s guidance directly: after enough consecutive failures (five, in this demo) the account locks for a fixed cooldown (60 seconds here), which throttles brute-force guessing of the 6-digit code down to a rate no attacker can usefully exploit.
Step 9: Test the whole flow end to end
Every scenario below was run against a live local instance of this server using real HTTP requests, not simulated. Start the server, then drive it with a client that shares the same secret (standing in for a real phone that scanned the QR code):
uvicorn step7_app:app --host 127.0.0.1 --port 8791
Signup and enrollment
signup: 200 {'secret_base32': 'SOQ432GHD4WTHIZAIWUXLGO7LYHNY2OQ', 'otpauth_uri': 'otpauth://totp/SXZ%20Demo%3Aamir2?secret=SOQ...&issuer=SXZ+Demo&algorithm=SHA1&digits=6&period=30'}
confirm-2fa-enrollment: 200 {'enabled': True, 'backup_codes': ['72216790', 'eef723ea', 'ca72a66a', '5f836c4e', '78120e53', '8260cc8e', '6530bfc2', 'fe8cd3ed']}
A real gotcha, discovered while testing: the enrollment code is already spent
Immediately after confirming enrollment, trying to log in with that exact same code fails:
verify-2fa (enrollment code): 401 {'detail': 'invalid or already-used code'}
This is correct, not a bug. The confirmation step in Step 8 sets last_used_step to the step matched by the enrollment code, precisely so that code cannot be replayed. If someone captured that enrollment traffic (over an unencrypted connection, say), they cannot immediately use the same code to log in afterward. The practical lesson: a code that was already used for anything, including proving a QR scan worked, is spent and will never validate again. Waiting for a fresh 30-second window and generating a new code works as expected:
login (2nd): 200 {'status': 'requires_2fa', 'temp_token': 'J5I9SB_C2G5DukSL20b1hQYH9l9uasYg'}
verify-2fa (fresh code): 200 {'status': 'logged_in', 'username': 'amir2', 'via': 'totp'}
Replay protection in the live server
login (3rd): 200 {'status': 'requires_2fa', 'temp_token': '0hOPAfc_...'}
verify-2fa (REPLAY of the code just used above): 401 {'detail': 'invalid or already-used code'}
Lockout after repeated failures
attempt 1: 401 {'detail': 'invalid or already-used code'}
attempt 2: 401 {'detail': 'invalid or already-used code'}
attempt 3: 401 {'detail': 'invalid or already-used code'}
attempt 4: 401 {'detail': 'invalid or already-used code'}
attempt 5: 429 {'detail': 'too many attempts, try again in 59s'}
attempt 6: 429 {'detail': 'too many attempts, try again in 59s'}
A second real gotcha: this lockout blocks backup codes too
verify-2fa (a genuinely valid backup code, submitted during the lockout): 429 {'detail': 'too many attempts, try again in 59s'}
Because the lockout check in Step 8 sits at the very top of verify_2fa, before the backup-code check ever runs, a locked-out account rejects everything, including a completely valid backup code the legitimate user has in hand. That is a real, deliberate tradeoff in this simple design, not an accident, and it is worth calling out explicitly rather than leaving it as a surprise; see the gotcha list below for how production systems usually handle it. Once the lockout expires, that same backup code works:
verify-2fa (same backup code, after the 60s lockout expired): 200 {'status': 'logged_in', 'username': 'amir2', 'via': 'backup_code'}
Backup codes are single-use
verify-2fa (reusing that same backup code again): 401 {'detail': 'invalid or already-used code'}
A wrong password never reaches the second factor at all
login (wrong password): 401 {'detail': 'invalid username or password'}
Common mistakes and gotchas
- Using a short or predictable secret. RFC 4226 requires at least 128 bits and recommends 160; always generate secrets with
secrets.token_bytes(), neverrandomor a short hardcoded string. - Relying on the algorithm or digits parameters in the otpauth URI. Mainstream authenticator apps largely ignore both and assume SHA-1 with 6 digits; test against a real app before shipping anything that deviates from the defaults.
- Checking only the exact current time step. Even a few seconds of network latency will cause legitimate logins to fail; use a small validation window (plus or minus one 30-second step is standard) instead.
- Forgetting replay protection. A windowed verifier without a per-user “last used step” check will accept the same code repeatedly for its entire validity window, which RFC 6238 explicitly forbids.
- Letting a hard lockout block every recovery path at once. As Step 9 demonstrated directly, a single shared lockout counter can leave a legitimate user holding a completely valid backup code unable to use it. Production systems commonly give backup codes their own separate, more lenient limit, since an 8-character hex code (32 bits of entropy) is far less guessable online than a 6-digit TOTP code (roughly 20 bits), and completely locking out account recovery has its own real cost.
- Storing backup codes in plaintext. Hash them the same way you would a password (SHA-256 is sufficient here since they are already high-entropy random values, unlike user-chosen passwords) and never log or display them again after the one-time enrollment response.
- Treating this tutorial’s code as production-ready as-is. It is correctness-verified against the RFCs and cross-checked against
pyotp, but a real system also needs encrypted-at-rest secret storage, audit logging, and session handling this demo intentionally leaves out to keep the core algorithm visible.
How to verify it all works end to end
- Run the Step 2 and Step 3 scripts and confirm every RFC test vector prints
OK. If any mismatch, stop and fix the algorithm before going further; nothing downstream can be correct if this is wrong. - Run the Step 4 cross-check against
pyotpwith a fresh random secret across at least a few consecutive time windows, not just one, to rule out a lucky single match. - Generate a real otpauth URI, print it, and manually parse it back apart to confirm the secret round-trips through URL encoding unchanged.
- Start the FastAPI server and walk through signup, enrollment confirmation, login, and verify-2fa with a client script that shares the same secret, exactly as in Step 9.
- Deliberately try to break it: submit a stale code, replay a used code, submit enough wrong codes to trigger the lockout, and use (then reuse) a backup code. Every one of those should fail in the specific way this tutorial describes, not silently succeed.
Next steps
TOTP is a solid, widely interoperable baseline, but it is not the strongest second factor available. WebAuthn and passkeys (built on public-key cryptography rather than a shared secret) remove phishing risk entirely, since a WebAuthn credential is cryptographically bound to the exact origin that registered it and simply will not authenticate against a lookalike phishing domain, something no six-digit code can prevent since a user can always be tricked into typing it into the wrong site. Hardware security keys implementing FIDO2/WebAuthn are worth exploring next if you want to go beyond what shared-secret TOTP can offer. For a deeper look at building an OAuth-based login flow from scratch, see this site’s tutorial on OAuth 2.0 with PKCE, and for the JSON Web Tokens that often carry a session after a successful 2FA login, see how to build and verify JWTs from scratch.








No Comment! Be the first one.