Multi-Factor Authentication with TOTP — Time-Based One-Time Passwords Deep Dive
In this tutorial, you will learn about Multi. We cover key concepts, practical examples, and best practices to help you master this topic.
TOTP (Time-based One-Time Password) generates a numeric code from a shared secret and the current Unix time, providing a second authentication factor that works offline and does not require network connectivity.
What You'll Learn
TOTP algorithm internals, secret provisioning via provisioning URIs and QR codes, time-step configuration, verification window tolerance, and backup Code Generation.
Why It Matters
TOTP is the most widely deployed MFA method. It works offline, does not require SMS infrastructure, and is supported by all authenticator apps (Google Authenticator, Authy, Microsoft Authenticator).
Real-World Use
GitHub, Google, and AWS all use TOTP for MFA. Durga Antivirus Pro uses TOTP for partner portal authentication, generating secrets via QR codes and verifying codes on each sensitive operation.
sequenceDiagram
participant User as User
participant App as Authenticator App
participant Server as Auth Server
Server->>Server: Generate random secret (160 bits)
Server->>App: Provisioning URI
otpauth://totp/Durga:user?secret=...&issuer=Durga
App->>App: Store secret in secure storage
Note over App: TOTP = HMAC-SHA1(secret, time_counter)[truncated]
User->>App: Read 6-digit code
User->>Server: Login + TOTP code
Server->>Server: Compute expected code
(current time ± 30s drift)
Server->>User: Access granted or denied
Code Example: TOTP Generation and Verification
import hmac, hashlib, struct, time, base64
from flask import Flask, request, jsonify
app = Flask(__name__)
def generate_totp_secret():
"""Generate a random base32-encoded secret for TOTP."""
import secrets
random_bytes = secrets.token_bytes(20) # 160 bits
return base64.b32encode(random_bytes).decode()
def totp(secret, time_step=30, digits=6):
"""Generate a TOTP code using HMAC-SHA1."""
# Get current time counter
counter = int(time.time() / time_step)
# Pack counter as 8-byte big-endian
counter_bytes = struct.pack(">Q", counter)
# Decode base32 secret
key = base64.b32decode(secret)
# Compute HMAC-SHA1
h = hmac.new(key, counter_bytes, hashlib.sha1).digest()
# Dynamic truncation
offset = h[-1] & 0x0F
truncated = struct.unpack(">I", h[offset:offset+4])[0] & 0x7FFFFFFF
# Return last 6 digits
return f"{truncated % 10**digits:0{digits}d}"
def verify_totp(secret, code, window=1, digits=6):
"""Verify a TOTP code with time drift window."""
current = int(time.time() / 30)
for i in range(-window, window + 1):
counter = current + i
counter_bytes = struct.pack(">Q", counter)
key = base64.b32decode(secret)
h = hmac.new(key, counter_bytes, hashlib.sha1).digest()
offset = h[-1] & 0x0F
truncated = struct.unpack(">I", h[offset:offset+4])[0] & 0x7FFFFFFF
expected = f"{truncated % 10**digits:0{digits}d}"
if hmac.compare_digest(code, expected):
return True
return False
Code Example: TOTP Provisioning API
import qrcode
import io
import base64 as b64
# User secrets store
totp_secrets = {}
@app.route("/api/mfa/totp/setup", methods=["POST"])
def setup_totp():
"""Generate TOTP secret and provisioning URI."""
user_id = request.json.get("user_id")
secret = generate_totp_secret()
totp_secrets[user_id] = secret
# Generate provisioning URI (standard format for authenticator apps)
provisioning_uri = (
f"otpauth://totp/Durga:{user_id}?"
f"secret={secret}&issuer=Durga&algorithm=SHA1&digits=6&period=30"
)
# Generate QR code as base64 PNG
qr = qrcode.QRCode(box_size=8, border=2)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image()
buffer = io.BytesIO()
img.save(buffer, format="PNG")
qr_b64 = b64.b64encode(buffer.getvalue()).decode()
return jsonify({
"secret": secret,
"provisioning_uri": provisioning_uri,
"qr_code_base64": qr_b64,
"backup_codes": generate_backup_codes(user_id),
"message": "Scan QR code with authenticator app"
})
@app.route("/api/mfa/totp/verify", methods=["POST"])
def verify_totp_code():
"""Verify a TOTP code."""
user_id = request.json.get("user_id")
code = request.json.get("code", "")
secret = totp_secrets.get(user_id)
if not secret:
return jsonify({"error": "TOTP not configured"}), 400
if verify_totp(secret, code):
return jsonify({"verified": True})
else:
return jsonify({"verified": False}), 401
Code Example: Backup Code Generation and Validation
import secrets, hashlib
# Backup codes per user
user_backup_codes = {}
def generate_backup_codes(user_id, count=8):
"""Generate one-time backup codes."""
codes = []
hashed_codes = []
for _ in range(count):
code = f"{secrets.randbelow(10**8):08d}"
codes.append(code)
hashed_codes.append(hashlib.sha256(code.encode()).hexdigest())
user_backup_codes[user_id] = {
"hashes": hashed_codes,
"used": set()
}
return codes # Return plain codes once — user must save them
@app.route("/api/mfa/totp/verify", methods=["POST"])
def verify_mfa():
"""Verify TOTP code or backup code."""
user_id = request.json.get("user_id")
code = request.json.get("code", "")
# Try TOTP first
secret = totp_secrets.get(user_id)
if secret and verify_totp(secret, code):
return jsonify({"verified": True, "method": "totp"})
# Try backup code
codes = user_backup_codes.get(user_id)
if codes:
code_hash = hashlib.sha256(code.encode()).hexdigest()
if code_hash in codes["hashes"] and code_hash not in codes["used"]:
codes["used"].add(code_hash)
return jsonify({
"verified": True,
"method": "backup",
"remaining_codes": len(codes["hashes"]) - len(codes["used"])
})
return jsonify({"verified": False}), 401
Common Mistakes
1. Zero Time Window
With zero tolerance for clock drift, users whose phones are a few seconds off will fail to authenticate. Use a window of +/- 1 time step (30 seconds).
2. Using SHA256/SHA512 Instead of SHA1
While SHA1 is cryptographically weaker than SHA256, TOTP uses HMAC-SHA1 which remains secure for this purpose. Most authenticator apps only support SHA1. Use SHA1 for compatibility.
3. Not Invalidating Used Backup Codes
Backup codes are single-use. After verification, mark them as used. Replay attacks using the same backup code must be prevented.
4. Revealing the Secret After Setup
The TOTP secret should only be shown once during setup. If the user needs to reconfigure, generate a new secret and invalidate the old one.
5. No Rate Limiting on Verification
Attackers can brute-force 6-digit TOTP codes (1M combinations). Rate limit to 5 attempts per minute per user. With a window of +/- 1, the attacker would need 3+ attempts per code position.
Practice Questions
- How does the TOTP algorithm generate time-based codes?
- What is the purpose of the provisioning URI?
- Why is a verification window needed?
- How do backup codes provide fallback access?
- Why should TOTP verification be rate-limited?
Answers:
- TOTP uses HMAC-SHA1 with a secret key and the current time counter (Unix time / 30). The HMAC output is dynamically truncated to produce a 6-digit code.
- The provisioning URI encodes the secret, issuer, user, and algorithm in a standard format (otpauth://) that authenticator apps can parse to configure TOTP.
- Device clocks drift by seconds. A window of +/- 1 time step (60 seconds total) accommodates typical clock skew without compromising security.
- Backup codes are one-time codes generated during setup. They allow access when the user has lost their authenticator device. Each code is single-use.
- With 1M possible codes and 30-second Windows, an attacker could brute-force quickly. Rate limiting to 5 attempts/minute makes brute-forcing infeasible.
Challenge: Build a complete TOTP MFA system with QR code provisioning, time-window verification, backup codes, rate limiting, and an endpoint that returns remaining backup codes.
FAQ
Mini Project
Build a TOTP MFA service with QR code provisioning, time-window TOTP verification, backup codes with hash storage, rate limiting, and a test script that verifies codes across time steps.
What's Next
Now explore Passwordless Authentication with Magic Links for eliminating passwords entirely from the auth flow.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro