TRENDING
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Build TOTP-Based Two-Factor Authentication From Scratch in Python
Learning Hub

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...

August 31, 2026 19 Min Read
104

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 (pyotp for Python, or a framework plugin like django-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:

  1. Compute HS = HMAC-SHA-1(K, C), a 20-byte value.
  2. Dynamic Truncation: use the low 4 bits of the last byte of HS as an offset into the 20 bytes, read 4 bytes starting at that offset, and clear the top bit to get a 31-bit number.
  3. 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(), never random or 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

  1. 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.
  2. Run the Step 4 cross-check against pyotp with a fresh random secret across at least a few consecutive time windows, not just one, to rule out a lucky single match.
  3. Generate a real otpauth URI, print it, and manually parse it back apart to confirm the secret round-trips through URL encoding unchanged.
  4. 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.
  5. 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.

Tags:

AuthenticationPythonSecurityTOTPTwo-Factor Authentication

Share

A technician's hand reaches into an open ATM, revealing exposed circuit boards, a screen, and a keypad above the machine's locked cash safe
Previous Post

Nine CryptoPro Vulnerabilities Turn ATM Security Into a Software Supply Chain Problem

Taipei 101 skyscraper rising above the Taipei skyline at sunset, representing Taiwanese chipmaker MediaTek
Next Post

Nvidia Invests $3.5 Billion in MediaTek as Big Tech Builds Its Own AI Chips

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Trending
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026