Most developers reach for a library when they need TOTP. That's fine for production — but if you don't understand what's underneath, you'll misconfigure the time window, miss the replay-attack problem, or ship without recovery codes. This article builds TOTP from the RFC spec so you know what every line is doing.
How TOTP Works (RFC 6238 Internals)
TOTP (Time-based One-Time Password, RFC 6238) is an extension of HOTP (RFC 4226). HOTP generates a code from an HMAC-SHA1 of a shared secret and an incrementing counter. TOTP replaces the counter with a time step derived from the current Unix timestamp:
T = floor(unix_timestamp / step) # step = 30 seconds by default
The code-generation pipeline is:
- Compute
T = floor(time.time() / 30)— the current 30-second window index - Pack
Tas a big-endian 8-byte unsigned integer - HMAC-SHA1(secret_bytes, T_bytes) → 20-byte digest
- Dynamic truncation: take the last nibble of the digest as an offset; extract 4 bytes at that offset; clear the sign bit; take the result modulo 1,000,000
The shared secret is a random byte string encoded in base32. Both the server and the authenticator app perform these steps independently and compare the result — no secret travels over the wire after enrollment.
Pure Python TOTP from Scratch
Here's a complete implementation using only Python's standard library — no external dependencies:
import hmac
import hashlib
import struct
import time
import base64
import os
def generate_secret(length: int = 20) -> str:
# Generate a random base32-encoded TOTP secret (160-bit).
return base64.b32encode(os.urandom(length)).decode("utf-8")
def _hotp(secret: str, counter: int) -> int:
# Compute HOTP per RFC 4226, Section 5.3.
key = base64.b32decode(secret.upper(), casefold=True)
msg = struct.pack(">Q", counter) # big-endian unsigned 64-bit
digest = hmac.new(key, msg, hashlib.sha1).digest()
offset = digest[-1] & 0x0F # dynamic offset
code = struct.unpack(">I", digest[offset:offset + 4])[0]
code &= 0x7FFFFFFF # clear the sign bit
return code % 1_000_000
def generate_totp(secret, timestamp=None, step=30):
# Return a zero-padded 6-digit TOTP code for the current time window.
t = int((timestamp or time.time()) / step)
return str(_hotp(secret, t)).zfill(6)
def verify_totp(secret, code, timestamp=None, step=30, window=1):
# Verify a TOTP code, accepting codes from adjacent time windows.
# window=1 tolerates +/-30 seconds of clock skew between client and server.
# Use hmac.compare_digest to prevent timing-based side-channel attacks.
t = int((timestamp or time.time()) / step)
for delta in range(-window, window + 1):
expected = str(_hotp(secret, t + delta)).zfill(6)
if hmac.compare_digest(expected, code):
return True
return False
A few implementation decisions worth calling out:
-
hmac.compare_digestis mandatory. Plain==comparison leaks timing information. An attacker measuring response times can narrow down the correct code digit by digit.compare_digestruns in constant time regardless of how many characters match. -
os.urandom, neverrandom. Python'srandommodule is a PRNG seeded from a deterministic state.os.urandompulls from the OS's cryptographically secure entropy pool. -
window=1is the RFC recommendation. It accepts the previous, current, and next 30-second code — covering ±30s of clock drift. Widening to 2 or more doubles or triples the effective brute-force window.
Generating QR Codes for Authenticator Apps
Users provision TOTP by scanning a QR code that encodes an otpauth:// URI. The format is standardized across Google Authenticator, Aegis, Bitwarden, and every RFC 6238-compatible app:
from urllib.parse import quote
def build_otpauth_uri(secret, account, issuer="MyApp"):
label = quote(f"{issuer}:{account}", safe="")
params = (
f"secret={secret}"
f"&issuer={quote(issuer)}"
f"&algorithm=SHA1"
f"&digits=6"
f"&period=30"
)
return f"otpauth://totp/{label}?{params}"
def print_qr_to_terminal(uri):
import qrcode # pip install qrcode[pil]
qr = qrcode.QRCode(border=1)
qr.add_data(uri)
qr.make(fit=True)
qr.print_ascii()
# Enrollment flow
secret = generate_secret()
uri = build_otpauth_uri(secret, account="alice@example.com", issuer="MyApp")
print_qr_to_terminal(uri)
# Save `secret` encrypted to the database; display QR once, never again
FastAPI Integration with Replay Attack Prevention
A minimal REST API with two routes — enroll and verify. The important addition here is the replay-attack guard: without it, a valid code intercepted by a man-in-the-middle can be reused within the same 30-second window.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
# Replace with encrypted DB storage in production
_secrets = {}
_used_codes = {}
class EnrollResponse(BaseModel):
otpauth_uri: str
secret: str # display once at enrollment; never again
class VerifyRequest(BaseModel):
user_id: str
code: str
@app.post("/totp/enroll/{user_id}", response_model=EnrollResponse)
def enroll(user_id: str):
secret = generate_secret()
_secrets[user_id] = secret
_used_codes[user_id] = set()
return EnrollResponse(
otpauth_uri=build_otpauth_uri(secret, account=user_id),
secret=secret,
)
@app.post("/totp/verify")
def verify(req: VerifyRequest):
secret = _secrets.get(req.user_id)
if not secret:
raise HTTPException(404, "User not enrolled")
used = _used_codes.setdefault(req.user_id, set())
if req.code in used:
raise HTTPException(400, "Code already used") # replay blocked
if not verify_totp(secret, req.code):
raise HTTPException(400, "Invalid or expired code")
used.add(req.code)
return {"status": "ok"}
Note the two layers of protection: the TOTP algorithm itself (valid for at most 90 seconds with window=1) plus the explicit per-code deduplication that blocks within-window replay.
Common Mistakes Worth Avoiding
No enrollment confirmation. Store the secret only after the user successfully verifies their first code. This catches QR scan errors before you commit a broken secret to the database and lock the user out immediately.
No recovery codes. If a device is lost, the user needs a way back in. Generate 8–10 single-use recovery codes at enrollment, store them bcrypt-hashed (not SHA-256 — short codes need a slow hash), and let users redeem them one at a time.
Logging TOTP codes. Short-lived credentials still qualify as credentials. Make sure your request logging middleware strips the code field before it reaches your log aggregator or error tracker.
Storing secrets in plaintext. TOTP secrets deserve the same treatment as passwords: encrypt at rest with a key managed separately from the database, such as envelope encryption via a KMS.
For a structured checklist covering TOTP enrollment flows, brute-force lockout, session binding, and secure logout, the free security hardening checklists at AYI NEDJIMI Consultants are a solid starting point.
The Takeaway
The TOTP algorithm itself is about 30 lines of Python. The security properties come from the surrounding decisions: constant-time comparison, replay prevention, proper entropy for secrets, and recovery paths for lost devices.
Using a library like pyotp in production is reasonable — it's well-tested and saves maintenance overhead. But having walked through the spec, you'll recognize when a configuration option actually matters and when a tutorial is quietly skipping the parts that get users locked out.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)