MFA Implementation in Python — Deep Dive

TOTP implementation from scratch

Understanding the algorithm helps you make better security decisions. TOTP (RFC 6238) is built on HOTP (RFC 4226):

import hmac
import hashlib
import struct
import time

def generate_totp(secret_bytes: bytes, time_step: int = 30, digits: int = 6) -> str:
    """Generate a TOTP code from raw secret bytes."""
    # Calculate time counter
    counter = int(time.time()) // time_step

    # HMAC-SHA1 of the counter
    counter_bytes = struct.pack(">Q", counter)  # 8-byte big-endian
    hmac_hash = hmac.new(secret_bytes, counter_bytes, hashlib.sha1).digest()

    # Dynamic truncation
    offset = hmac_hash[-1] & 0x0F
    truncated = struct.unpack(">I", hmac_hash[offset:offset + 4])[0]
    truncated &= 0x7FFFFFFF  # clear sign bit

    # Extract digits
    code = truncated % (10 ** digits)
    return str(code).zfill(digits)

The base32-encoded secret from pyotp.random_base32() needs decoding before use:

import base64

secret_b32 = "JBSWY3DPEHPK3PXP"
secret_bytes = base64.b32decode(secret_b32)
code = generate_totp(secret_bytes)

Production TOTP with pyotp and QR codes

import pyotp
import qrcode
import io
import base64

class MFAManager:
    def __init__(self, issuer: str = "MyApp"):
        self.issuer = issuer

    def enroll(self, user_email: str) -> dict:
        """Generate MFA enrollment data."""
        secret = pyotp.random_base32()
        totp = pyotp.TOTP(secret)
        uri = totp.provisioning_uri(name=user_email, issuer_name=self.issuer)

        # Generate QR code as base64 PNG
        qr = qrcode.make(uri)
        buffer = io.BytesIO()
        qr.save(buffer, format="PNG")
        qr_b64 = base64.b64encode(buffer.getvalue()).decode()

        # Generate backup codes
        backup_codes = [pyotp.random_base32()[:8] for _ in range(10)]

        return {
            "secret": secret,  # store encrypted in database
            "qr_data_uri": f"data:image/png;base64,{qr_b64}",
            "backup_codes": backup_codes,  # store hashed in database
            "uri": uri,
        }

    def verify(self, secret: str, code: str, valid_window: int = 1) -> bool:
        """Verify a TOTP code with clock tolerance."""
        totp = pyotp.TOTP(secret)
        return totp.verify(code, valid_window=valid_window)

The valid_window=1 parameter accepts codes from 30 seconds before and after the current window — a 90-second total window that handles most clock drift without being so wide that brute-forcing becomes practical.

Preventing code reuse

A TOTP code is valid for 30 seconds (plus the tolerance window). If an attacker intercepts a code in real-time, they could use it within that window. Prevent replay by tracking used codes:

import time
from functools import lru_cache

class ReplayProtectedMFA:
    def __init__(self):
        self.used_codes = {}  # user_id -> set of (code, time_step)

    def verify(self, user_id: str, secret: str, code: str) -> bool:
        totp = pyotp.TOTP(secret)
        current_step = int(time.time()) // 30

        # Check if code was already used in this time window
        user_used = self.used_codes.get(user_id, set())
        if (code, current_step) in user_used:
            return False

        if totp.verify(code, valid_window=1):
            # Mark code as used
            user_used.add((code, current_step))
            # Clean old entries
            user_used = {(c, s) for c, s in user_used if s >= current_step - 2}
            self.used_codes[user_id] = user_used
            return True

        return False

In production, use Redis or a similar store for this — the in-memory approach doesn’t work across multiple server instances.

Storing secrets securely

The MFA secret is effectively a second password. Store it encrypted at rest:

from cryptography.fernet import Fernet

# Generate encryption key (store in environment variable / secrets manager)
encryption_key = Fernet.generate_key()
cipher = Fernet(encryption_key)

def encrypt_secret(secret: str) -> bytes:
    return cipher.encrypt(secret.encode())

def decrypt_secret(encrypted: bytes) -> str:
    return cipher.decrypt(encrypted).decode()

# In your user model / database:
# mfa_secret_encrypted = encrypt_secret(secret)
# Later: secret = decrypt_secret(user.mfa_secret_encrypted)

Backup codes implementation

Backup codes must be hashed (not stored in plaintext) since they’re single-use passwords:

import bcrypt
import secrets

def generate_backup_codes(count: int = 10) -> tuple[list[str], list[bytes]]:
    """Return (plaintext codes for user, hashed codes for database)."""
    codes = [secrets.token_hex(4).upper() for _ in range(count)]  # e.g., "A1B2C3D4"
    hashed = [bcrypt.hashpw(c.encode(), bcrypt.gensalt()) for c in codes]
    return codes, hashed

def verify_backup_code(code: str, hashed_codes: list[bytes]) -> int | None:
    """Return index of matching code, or None."""
    for i, hashed in enumerate(hashed_codes):
        if bcrypt.checkpw(code.encode(), hashed):
            return i  # caller removes this index from the stored list
    return None

WebAuthn / FIDO2: Hardware security keys

TOTP is good. Hardware keys (YubiKey, Titan) are better — they’re phishing-resistant because the key verifies the website’s domain during authentication.

# pip install py_webauthn
from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
)
from webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    UserVerificationRequirement,
    PublicKeyCredentialDescriptor,
)

# Registration (enrollment)
options = generate_registration_options(
    rp_id="example.com",
    rp_name="My Application",
    user_id=b"unique-user-id",
    user_name="user@example.com",
    user_display_name="Jane Smith",
    authenticator_selection=AuthenticatorSelectionCriteria(
        user_verification=UserVerificationRequirement.PREFERRED,
    ),
)
# Send options to the browser, receive credential back
# verification = verify_registration_response(
#     credential=credential_from_browser,
#     expected_challenge=options.challenge,
#     expected_rp_id="example.com",
#     expected_origin="https://example.com",
# )

WebAuthn is more complex to implement than TOTP but provides the strongest protection against phishing and credential theft.

Flask integration: complete MFA flow

from flask import Flask, request, session, jsonify, redirect

app = Flask(__name__)
mfa_manager = MFAManager(issuer="MyApp")

@app.route("/mfa/setup", methods=["POST"])
def mfa_setup():
    """Start MFA enrollment."""
    user = get_current_user()
    enrollment = mfa_manager.enroll(user.email)

    # Temporarily store secret until confirmed
    session["pending_mfa_secret"] = enrollment["secret"]

    return jsonify({
        "qr_data_uri": enrollment["qr_data_uri"],
        "backup_codes": enrollment["backup_codes"],
    })

@app.route("/mfa/confirm", methods=["POST"])
def mfa_confirm():
    """Confirm MFA setup with a code from the authenticator."""
    code = request.json["code"]
    secret = session.get("pending_mfa_secret")

    if not secret:
        return jsonify({"error": "No pending MFA setup"}), 400

    if not mfa_manager.verify(secret, code):
        return jsonify({"error": "Invalid code"}), 400

    user = get_current_user()
    user.mfa_secret = encrypt_secret(secret)
    user.mfa_enabled = True
    user.save()

    session.pop("pending_mfa_secret")
    return jsonify({"status": "MFA enabled"})

@app.route("/login", methods=["POST"])
def login():
    """Two-step login with MFA."""
    username = request.json["username"]
    password = request.json["password"]

    user = authenticate_user(username, password)
    if not user:
        return jsonify({"error": "Invalid credentials"}), 401

    if user.mfa_enabled:
        session["pending_user_id"] = user.id
        return jsonify({"requires_mfa": True})

    session["user_id"] = user.id
    return jsonify({"status": "logged in"})

@app.route("/login/mfa", methods=["POST"])
def login_mfa():
    """Verify MFA code after password authentication."""
    user_id = session.get("pending_user_id")
    if not user_id:
        return jsonify({"error": "Complete password step first"}), 400

    user = get_user(user_id)
    code = request.json["code"]
    secret = decrypt_secret(user.mfa_secret)

    if mfa_manager.verify(secret, code):
        session.pop("pending_user_id")
        session["user_id"] = user.id
        return jsonify({"status": "logged in"})

    return jsonify({"error": "Invalid MFA code"}), 401

Rate limiting MFA attempts

Without rate limiting, an attacker can brute-force the 6-digit code space (1 million combinations) quickly:

from datetime import datetime, timedelta
from collections import defaultdict

class MFARateLimiter:
    def __init__(self, max_attempts: int = 5, window_seconds: int = 300):
        self.max_attempts = max_attempts
        self.window = timedelta(seconds=window_seconds)
        self.attempts = defaultdict(list)

    def check(self, user_id: str) -> bool:
        """Return True if attempt is allowed."""
        now = datetime.utcnow()
        cutoff = now - self.window

        # Clean old attempts
        self.attempts[user_id] = [
            t for t in self.attempts[user_id] if t > cutoff
        ]

        if len(self.attempts[user_id]) >= self.max_attempts:
            return False

        self.attempts[user_id].append(now)
        return True

Five attempts per 5-minute window is reasonable. After exhaustion, require the user to wait or use a backup code through a different flow.

Production security checklist

  • Encrypt MFA secrets at rest — they’re equivalent to passwords
  • Rate-limit verification attempts — prevent brute-force of the 6-digit space
  • Track code reuse — reject the same code within its validity window
  • Require re-authentication before MFA changes — don’t let a hijacked session disable MFA
  • Provide backup codes — hash them like passwords, display only once
  • Support multiple MFA methods — TOTP + WebAuthn gives users flexibility and resilience
  • Log MFA events — enrollment, successful verification, failed attempts, recovery code use
  • Don’t reveal MFA status before password — an attacker shouldn’t know which accounts have MFA enabled

The one thing to remember: Production MFA requires more than just TOTP code verification — you need encrypted secret storage, replay protection, rate limiting, backup codes, and a UX flow that handles enrollment, verification, and recovery without creating security gaps.

pythonsecurityauthenticationweb

See Also