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.
See Also
- Python Api Key Management Why apps use special passwords called API keys, and how to keep them safe — explained with a library card analogy
- Python Attribute Based Access Control How apps make fine-grained permission decisions based on who you are, what you're accessing, and the circumstances — explained with an airport analogy
- Python Audit Logging Learn Audit Logging with a clear mental model so your Python code is easier to trust and maintain.
- Python Bandit Security Scanning Why Bandit Security Scanning helps Python teams catch painful mistakes early without slowing daily development.
- Python Clickjacking Prevention How invisible website layers trick you into clicking the wrong thing, and how Python apps stop it