Two-Factor & Multi-Factor Authentication -- Complete Implementation Guide
In this tutorial, you'll learn about Two. We cover key concepts, practical examples, and best practices.
Multi-factor authentication requires two or more independent verification factors -- typically something you know (password), something you have (phone or hardware key), and something you are (biometric) -- reducing account takeover risk by over 99%.
What You'll Learn
You will learn to implement TOTP verification in a web application, deploy WebAuthn passkeys with platform authenticators, integrate hardware security keys, configure step-up authentication for sensitive actions, and design a complete MFA recovery flow.
Why It Matters
Google's 2024 threat analysis report shows that SMS-based authentication blocks 96% of bulk phishing but only 76% of targeted attacks. WebAuthn blocks 99.8% of all phishing attacks because the cryptographic assertion is bound to the origin.
Real-World Use
A SaaS platform enforces WebAuthn for all admin accounts. When an admin receives a phishing email and clicks the link, the browser refuses to authenticate because the origin in the credential assertion (attacker-site.com) does not match the registered origin (admin.saas-platform.com), preventing credential theft.
MFA Factor Types
flowchart TD
A[Authentication Factors] --> B[Knowledge]
A --> C[Possession]
A --> D[Inherence]
B --> E[Password]
B --> F[PIN]
B --> G[Security Question]
C --> H[Phone / TOTP App]
C --> I[Hardware Security Key]
C --> J[Smart Card]
D --> K[Fingerprint]
D --> L[Face Scan]
D --> M[Voice Pattern]
style A fill:#4a9,stroke:#333
style C fill:#f96,stroke:#333
How it works: True MFA uses at least two factors from different categories. A password (knowledge) plus a TOTP code from your phone (possession) is valid MFA. Two passwords (knowledge + knowledge) is not MFA -- it is just two steps of the same factor type.
TOTP Server-Side Implementation
import pyotp
import hashlib
import base64
import os
from datetime import datetime
class TOTPManager:
def __init__(self, issuer="DodaTech"):
self.issuer = issuer
def generate_secret(self):
"""Generate a new TOTP secret."""
secret = pyotp.random_base32()
return secret
def get_provisioning_uri(self, secret, email):
"""Get the otpauth:// URI for QR code generation."""
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(email, issuer_name=self.issuer)
return uri
def verify_code(self, secret, code):
"""Verify a TOTP code within a 90-second window."""
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
def get_current_code(self, secret):
"""Get the current valid TOTP code (for testing)."""
totp = pyotp.TOTP(secret)
return totp.now()
# Usage
manager = TOTPManager()
secret = manager.generate_secret()
print(f"Secret: {secret}")
# Expected: JBSWY3DPEHPK3PXP
uri = manager.get_provisioning_uri(secret, "user@dodatech.com")
print(f"URI: {uri}")
# Expected: otpauth://totp/DodaTech:user@dodatech.com?secret=JBSWY3DPEHPK3PXP&issuer=DodaTech
# Simulate verification
code = manager.get_current_code(secret)
result = manager.verify_code(secret, code)
print(f"Verification result: {result}")
# Expected: True
wrong_code = "000000"
result = manager.verify_code(secret, wrong_code)
print(f"Wrong code result: {result}")
# Expected: False
Expected behavior: The TOTP secret is generated as a random base32 string. The provisioning URI encodes the secret for QR code generation. Verification succeeds for the current code with a 90-second window (+/- 30 seconds) to handle clock drift.
WebAuthn Registration and Authentication
// WebAuthn registration with platform authenticator
async function registerPasskey(username, userId) {
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredential = await navigator.credentials.create({
publicKey: {
challenge,
rp: {
name: "DodaTech",
id: window.location.hostname,
},
user: {
id: new TextEncoder().encode(userId),
name: username,
displayName: username,
},
pubKeyCredParams: [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -257 },
],
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "required",
residentKey: "required",
},
},
});
return {
id: publicKeyCredential.id,
rawId: Array.from(new Uint8Array(publicKeyCredential.rawId)),
type: publicKeyCredential.type,
clientDataJSON: Array.from(
new Uint8Array(publicKeyCredential.response.clientDataJSON)
),
attestationObject: Array.from(
new Uint8Array(publicKeyCredential.response.attestationObject)
),
};
}
// WebAuthn authentication
async function authenticateWithPasskey() {
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const assertion = await navigator.credentials.get({
publicKey: {
challenge,
timeout: 60000,
userVerification: "required",
},
});
return {
id: assertion.id,
rawId: Array.from(new Uint8Array(assertion.rawId)),
type: assertion.type,
clientDataJSON: Array.from(
new Uint8Array(assertion.response.clientDataJSON)
),
authenticatorData: Array.from(
new Uint8Array(assertion.response.authenticatorData)
),
signature: Array.from(new Uint8Array(assertion.response.signature)),
userHandle: assertion.response.userHandle
? Array.from(new Uint8Array(assertion.response.userHandle))
: null,
};
}
// Usage
registerPasskey("admin@dodatech.com", "admin-001")
.then((cred) => console.log("Passkey registered:", cred.id))
.catch((err) => console.error("Registration failed:", err.message));
Expected behavior: The browser prompts for biometric or PIN verification. On success, the credential object returns a public key and credential ID. The server stores the public key. During authentication, the browser creates a signed assertion proving possession of the private key.
Step-Up Authentication
# Step-up authentication for sensitive actions
from functools import wraps
from flask import session, request, jsonify
STEP_UP_ACTIONS = {
"password_change": "MFA required for password changes",
"api_key_generate": "MFA required for API key generation",
"payment": "MFA required for payments over $1000",
}
def require_step_up(action):
"""Decorator that requires MFA for sensitive actions."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = session.get("user_id")
if not user_id:
return jsonify({"error": "Not authenticated"}), 401
# Check if user has already authenticated via MFA in this session
mfa_verified = session.get(f"mfa_verified_{action}", False)
mfa_timestamp = session.get(f"mfa_timestamp_{action}", 0)
from time import time
# MFA is valid for 15 minutes per action
if mfa_verified and (time() - mfa_timestamp) < 900:
return f(*args, **kwargs)
# Require MFA verification
return jsonify({
"error": "step_up_required",
"action": action,
"message": STEP_UP_ACTIONS.get(action, "MFA required"),
"session_token": session.get("step_up_token"),
}), 403
return decorated_function
return decorator
# Apply step-up to sensitive routes
@app.route("/api/settings/password", methods=["POST"])
@require_step_up("password_change")
def change_password():
# Password change logic here
return jsonify({"status": "password_updated"})
@app.route("/api/verify-step-up", methods=["POST"])
def verify_step_up():
"""Verify MFA for step-up authentication."""
data = request.get_json()
code = data.get("code")
action = data.get("action")
# Verify TOTP code
# ... verification logic ...
from time import time
session[f"mfa_verified_{action}"] = True
session[f"mfa_timestamp_{action}"] = time()
return jsonify({"status": "verified"})
Expected behavior: Normal page views require only password authentication. Sensitive actions like changing the password or generating an API key trigger a step-up challenge. The user must provide a second factor valid for 15 minutes before the sensitive action is permitted.
Recovery Code Workflow
import hashlib
import os
import json
class RecoveryCodeManager:
def __init__(self, code_count=10, code_length=12):
self.code_count = code_count
self.code_length = code_length
def generate_codes(self):
"""Generate recovery codes and their hashed versions."""
codes = []
hashed_codes = []
for _ in range(self.code_count):
code = os.urandom(self.code_length).hex()[:self.code_length]
hashed = hashlib.sha256(code.encode()).hexdigest()
codes.append(code)
hashed_codes.append(hashed)
return codes, hashed_codes
def verify_code(self, code, hashed_codes, used_codes):
"""Verify a recovery code and mark it as used."""
code_hash = hashlib.sha256(code.encode()).hexdigest()
if code_hash in used_codes:
return False, "Code already used"
if code_hash in hashed_codes:
return True, code_hash
return False, "Invalid code"
# Usage
manager = RecoveryCodeManager()
codes, hashed = manager.generate_codes()
print("Save these recovery codes:")
for code in codes:
print(f" {code}")
# Expected: 12-character hex strings (printed once during enrollment)
used_codes = set()
valid, result = manager.verify_code(codes[0], hashed, used_codes)
print(f"First code valid: {valid}")
# Expected: True
used_codes.add(result)
valid, result = manager.verify_code(codes[0], hashed, used_codes)
print(f"Reuse attempt: {valid}")
# Expected: False (code already used)
Expected behavior: Recovery codes are generated on the client during enrollment. Only SHA-256 hashes are stored on the server. Each code can be used exactly once. After all codes are consumed, the user must re-enroll in MFA.
Common Errors
TOTP clock drift causes false rejections -- The authenticator app clock drifts more than 30 seconds from the server clock. Users report that codes "never work." Sync server time with NTP and log clock skew during verification attempts.
WebAuthn registration fails on unsupported browsers -- Firefox on Linux or older Safari versions lack full FIDO2 support. Detect platform support before presenting WebAuthn as an option and fall back to TOTP.
Hardware key PIN lockout -- The user enters the wrong PIN 3 times and the security key is blocked. Implement a warning before the final attempt and provide instructions for resetting the key (which deletes all credentials).
Session MFA state not cleared on logout -- The user logs out but the server still holds the mfa_verified flag in the old session. A new session does not require MFA for step-up actions. Clear all MFA session flags on logout.
Recovery code stored in browser local storage -- The user saves recovery codes in browser local storage during enrollment. A cross-site scripting attack exfiltrates the codes. Prompt users to print or download codes, never store them in the browser.
Practice Questions
Why is SMS-based MFA considered the least secure method? SMS is vulnerable to SIM swapping attacks where the attacker convinces the carrier to transfer the phone number, intercepting SMS codes. SMS codes can also be intercepted via SS7 protocol vulnerabilities. Use TOTP or WebAuthn instead.
How does WebAuthn prevent phishing attacks that bypass TOTP? WebAuthn binds credentials to a specific origin. The browser includes the origin in the cryptographic assertion. A phishing site at evil.com cannot authenticate using credentials registered for dodatech.com because the origin check fails.
What is the purpose of step-up authentication? Step-up authentication requires additional factors only for sensitive actions rather than every login. This balances security with user convenience. Normal browsing requires only a password while password changes and financial transactions require MFA.
How should clock drift between TOTP devices be handled? Use a verification window of +/- 1 step (90 seconds total) to tolerate up to 30 seconds of clock drift. Log the clock skew with each verification to detect devices drifting out of tolerance. Periodically synchronize server clocks with NTP.
Challenge: Build a Flask application with TOTP enrollment, WebAuthn fallback, step-up authentication for admin actions, and recovery code generation. Test the full flow: enroll, login, perform step-up action, recover from lost device, and verify that used recovery codes are rejected.
Mini Project
Create a complete MFA enforcement system for a web application. Support TOTP authenticator apps as the primary factor and WebAuthn passkeys as a secondary option. Implement step-up authentication for password changes and API key generation. Build a recovery flow with 10 single-use codes. Test all paths including device loss, browser upgrade, and clock drift scenarios.
FAQ
What is the difference between 2FA and MFA?
2FA is a subset of MFA that uses exactly two factors. MFA uses two or more factors. Every 2FA implementation is MFA, but not all MFA is limited to two factors. Enterprises may require three factors (password + phone + fingerprint) for high-security access.
Can I use the same hardware key for multiple services?
Yes, hardware security keys support multiple credentials. Each service registers a unique credential on the key. The key stores up to 25-100 credentials depending on the model. The private keys never leave the hardware secure element.
What happens if I lose my phone with the authenticator app?
Use recovery codes that were generated during enrollment. Each code provides one-time access to your account without MFA. After regaining access, disable MFA on the lost device and enroll a new device with fresh recovery codes.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro