DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Implementing TOTP Two-Factor Authentication from Scratch in Python

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
Enter fullscreen mode Exit fullscreen mode

The code-generation pipeline is:

  1. Compute T = floor(time.time() / 30) — the current 30-second window index
  2. Pack T as a big-endian 8-byte unsigned integer
  3. HMAC-SHA1(secret_bytes, T_bytes) → 20-byte digest
  4. 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
Enter fullscreen mode Exit fullscreen mode

A few implementation decisions worth calling out:

  • hmac.compare_digest is mandatory. Plain == comparison leaks timing information. An attacker measuring response times can narrow down the correct code digit by digit. compare_digest runs in constant time regardless of how many characters match.
  • os.urandom, never random. Python's random module is a PRNG seeded from a deterministic state. os.urandom pulls from the OS's cryptographically secure entropy pool.
  • window=1 is 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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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)