Implementing TOTP-Based MFA
Password breaches are routine. Credential stuffing works because users reuse passwords. MFA adds a second factor that attackers cannot obtain from a database dump — a time-limited code generated by something the user possesses (their phone) or knows (a backup code).
TOTP (Time-based One-Time Password, RFC 6238) is the most widely deployed software MFA method. It powers Google Authenticator, Authy, 1Password, and every major platform's 2FA. Implementation is straightforward, but the details around secret storage, clock drift, enrollment UX, and recovery determine whether your MFA actually protects users or just annoys them.
How TOTP works
Both server and authenticator app share a secret key. The code is:
TOTP = HOTP(secret, floor(current_unix_time / 30))
HOTP = truncate(HMAC-SHA1(secret, counter))
The code is six digits, valid for 30 seconds. Server and app independently compute the same code from the same secret and time.
Enrollment flow
Generate a secret, show a QR code, verify the user can produce valid codes, then activate:
import pyotp
import qrcode
import io, base64
from cryptography.fernet import Fernet
def enroll_totp(user_id: str) -> dict:
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(
name=user.email,
issuer_name="MyApp",
)
# Generate QR code
qr = qrcode.make(provisioning_uri)
buffer = io.BytesIO()
qr.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
# Store encrypted secret (not yet activated)
encrypted_secret = encrypt_secret(secret)
db.store_pending_totp(user_id, encrypted_secret)
return {
"qr_code": qr_base64,
"secret": secret, # show for manual entry
"provisioning_uri": provisioning_uri,
}
The user scans the QR code or manually enters the secret. Then verify they configured it correctly:
def verify_enrollment(user_id: str, code: str) -> bool:
encrypted_secret = db.get_pending_totp(user_id)
secret = decrypt_secret(encrypted_secret)
totp = pyotp.TOTP(secret)
if totp.verify(code, valid_window=1):
db.activate_totp(user_id)
backup_codes = generate_backup_codes(user_id, count=10)
return {"success": True, "backup_codes": backup_codes}
return {"success": False}
Only activate MFA after successful verification. Generate backup codes at activation — this is the user's only recovery path if they lose their device.
Verification during login
After password authentication succeeds, require TOTP:
def verify_totp_login(user_id: str, code: str) -> bool:
encrypted_secret = db.get_active_totp(user_id)
if not encrypted_secret:
return True # MFA not enabled, skip
secret = decrypt_secret(encrypted_secret)
totp = pyotp.TOTP(secret)
if totp.verify(code, valid_window=1):
return True
# Check backup codes as fallback
if verify_backup_code(user_id, code):
return True
return False
Track failed attempts and rate-limit — lock out after 5 consecutive failures for 15 minutes.
Secret storage
Never store TOTP secrets in plaintext:
from cryptography.fernet import Fernet
class SecretVault:
def __init__(self, kms_key_id: str):
self.fernet = Fernet(self._load_data_key(kms_key_id))
def encrypt(self, secret: str) -> bytes:
return self.fernet.encrypt(secret.encode())
def decrypt(self, encrypted: bytes) -> str:
return self.fernet.decrypt(encrypted).decode()
Use a dedicated encryption key from AWS KMS, GCP Cloud KMS, or HashiCorp Vault — separate from database-level encryption.
Backup codes
One-time recovery codes for device loss:
import secrets
import hashlib
def generate_backup_codes(user_id: str, count: int = 10) -> list[str]:
codes = [secrets.token_hex(4).upper() for _ in range(count)] # 8-char codes
hashed = [hashlib.sha256(c.encode()).hexdigest() for c in codes]
db.store_backup_codes(user_id, hashed)
return codes # show once to user, never stored in plaintext
def verify_backup_code(user_id: str, code: str) -> bool:
code_hash = hashlib.sha256(code.encode()).hexdigest()
if db.consume_backup_code(user_id, code_hash):
notify_user(user_id, "A backup code was used. If this wasn't you, reset your MFA.")
return True
return False
Each backup code works once. Hash them like passwords. Notify the user when one is consumed.
Recovery flows
Users will lose their phone. Plan for it:
- Backup codes — primary recovery. User enters a backup code instead of TOTP.
- Admin reset — support team verifies identity (video call, ID check) and resets MFA.
- Recovery email — send a time-limited link to disable MFA (requires access to registered email + password).
- Recovery key — shown once at enrollment, stored by the user offline.
Never leave a user permanently locked out. Every MFA implementation needs a recovery path that does not bypass security entirely.
Security considerations
- Rate limit verification attempts — 5 failures per 15 minutes per user.
- Do not leak MFA status — login should not reveal whether MFA is enabled before password verification.
- Secure the enrollment endpoint — requires authenticated session.
- Audit log all MFA events — enrollment, verification, failures, backup code use, resets.
- Allow ±1 time window only — wider windows increase brute-force surface.
# Brute force math: 6-digit code = 1,000,000 possibilities
# At ±1 window = 3 valid codes at any time
# At 5 attempts per 15 min = 480 attempts/day
# Expected time to brute force: ~2,083 days (acceptable)
# At ±5 window = 11 valid codes → do NOT do this
Common production mistakes
Teams get mfa totp implementation wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
Production implementations of mfa totp implementation fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.
Resources
- RFC 6238: TOTP specification
- RFC 4226: HOTP specification
- pyotp Python library
- OWASP Authentication Cheat Sheet
- Google Authenticator key URI format
Frequently asked questions
What is the difference between TOTP and HOTP?
TOTP (Time-based OTP) generates codes from the current time and a shared secret — codes change every 30 seconds. HOTP (HMAC-based OTP) generates codes from an incrementing counter. TOTP is what Google Authenticator, Authy, and 1Password use. HOTP is common in hardware tokens. TOTP is preferred for software authenticators because it self-synchronizes via time.
How many time steps should I allow for clock drift?
Allow ±1 time step (30 seconds) by default — this covers most clock drift between server and phone. For environments with known time sync issues, allow ±2 steps. Never allow more than ±2 or brute-force becomes feasible (5 valid codes at any moment with ±2).
How should I store TOTP secrets?
Encrypt TOTP secrets at rest with AES-256 using a key from a KMS or HSM. Never store secrets in plaintext. The encryption key should be separate from the database encryption key so a database breach alone does not expose MFA secrets.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →