Implementing TOTP-Based MFA

SECAuthenticationMFASecurity
Share on LinkedIn

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:

  1. Backup codes — primary recovery. User enters a backup code instead of TOTP.
  2. Admin reset — support team verifies identity (video call, ID check) and resets MFA.
  3. Recovery email — send a time-limited link to disable MFA (requires access to registered email + password).
  4. 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

# 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:

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

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 →