Published
- 37 min read
How to Add Multi-Factor Authentication to Your App
How to Write, Ship, and Maintain Code Without Shipping Vulnerabilities
A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.
Buy the book now
Practical Digital Survival for Whistleblowers, Journalists, and Activists
A practical guide to digital anonymity for people who can’t afford to be identified. Designed for whistleblowers, journalists, and activists operating under real-world risk.
Buy the book now
The Digital Fortress: How to Stay Safe Online
A simple, no-jargon guide to protecting your digital life from everyday threats. Learn how to secure your accounts, devices, and privacy with practical steps anyone can follow.
Buy the book nowIntroduction
With the increasing sophistication of cyberattacks, Multi-Factor Authentication (MFA) has become a cornerstone of application security. By requiring users to provide multiple verification factors, MFA significantly enhances the security of user accounts, protecting them from unauthorized access.
This article provides a comprehensive guide to implementing MFA in your applications, covering its benefits, implementation strategies, and best practices.
Understanding the MFA Threat Landscape
Multi-Factor Authentication exists because password-based authentication has demonstrably failed to protect accounts at scale. Understanding the specific attack vectors that MFA is designed to counter helps developers make better decisions about which MFA method to implement and where to apply it within their applications.
Credential Stuffing
Credential stuffing is the automated injection of stolen username and password combinations from one data breach into another service’s login endpoint. Attackers purchase or download breach databases — which now collectively contain billions of credentials from hundreds of historical incidents — and run them against login APIs using tools that automatically handle rate limiting and proxy rotation. Because users routinely reuse passwords across multiple services, a meaningful fraction of these credentials will also work at the target site. MFA is the most reliable defense against credential stuffing because a valid username and password alone is no longer sufficient to access an account.
Phishing and Adversary-in-the-Middle Attacks
Phishing tricks users into entering their credentials on a page that looks like a legitimate service but is controlled by the attacker. Traditional MFA based on TOTP or SMS partially mitigates phishing, but adversary-in-the-middle (AiTM) phishing proxies can intercept and relay the one-time password in real time during the user’s session — effectively stealing both the password and the OTP simultaneously. Tools that automate this class of attack are openly available and have been used against large enterprises. WebAuthn and FIDO2 passkeys are the only widely deployed MFA methods that completely defeat AiTM phishing, because the credential is cryptographically bound to the exact origin it was registered with. A credential issued to myapp.example.com cannot be used to authenticate against any other domain, even one that visually mimics the legitimate site.
SIM Swapping
SIM swapping is a social engineering attack in which an attacker convinces a mobile carrier to transfer a victim’s phone number to a SIM card under the attacker’s control. Once the number is transferred, the attacker receives all SMS messages and voice calls intended for the victim — including SMS one-time passwords. High-profile individuals, cryptocurrency holders, and corporate executives have repeatedly lost significant assets through SIM swap attacks, and the technique has compromised accounts at major technology companies. This attack vector is the primary reason NIST SP 800-63B classifies SMS as a “restricted” authentication factor and recommends against its use for high-value applications that contain financial data or personally identifiable information.
Brute Force and Password Spraying
Brute force attacks systematically attempt every possible password combination for a specific account. Password spraying takes the opposite approach — trying a small set of extremely common passwords across a very large number of accounts to stay beneath per-account lockout thresholds. Both attacks are well-understood and widely automated. MFA adds a second obstacle that neither brute force nor spraying can easily overcome, because knowing the password is no longer sufficient to gain access. However, as detailed later in the anti-patterns section, brute force against the MFA layer itself is still possible if you fail to implement rate limiting and attempt counters on your MFA verification endpoint.
Why MFA Matters by the Numbers
Microsoft’s analysis of Azure Active Directory accounts found that enabling MFA would have blocked approximately 99.9 percent of the automated account compromise attempts observed in their telemetry. The Verizon Data Breach Investigations Report consistently identifies compromised credentials as a factor in the majority of hacking-related breaches every year. These figures underline why MFA is no longer optional for any application that stores user accounts — the question is not whether to implement it, but which method best fits your threat model and user base.
Why Multi-Factor Authentication Matters
1. Enhanced Account Security
Passwords alone are no longer sufficient. MFA adds an extra layer of security by requiring a combination of factors, such as something the user knows (password), has (authenticator app or hardware token), or is (biometric verification).
2. Mitigates Common Attacks
MFA protects against password-related attacks, such as brute force, phishing, and credential stuffing.
3. Regulatory Compliance
Many industries require MFA for compliance with standards like GDPR, HIPAA, and PCI DSS.
4. Boosts User Trust
Implementing MFA demonstrates a commitment to user security, fostering trust and confidence.
Types of Multi-Factor Authentication
1. Time-Based One-Time Passwords (TOTP)
- Users receive a time-sensitive code generated by an authenticator app like Google Authenticator or Authy.
- Pros: Easy to implement, widely supported.
- Cons: Requires users to install an app.
2. Push Notifications
- Users approve or deny a login attempt via a mobile notification.
- Pros: Seamless user experience.
- Cons: Requires a reliable mobile app infrastructure.
3. SMS-Based OTP
- Codes are sent via SMS for verification.
- Pros: Universally accessible.
- Cons: Vulnerable to SIM-swapping attacks.
4. Hardware Tokens
- Users carry physical devices, such as YubiKeys, for authentication.
- Pros: Highly secure.
- Cons: Additional cost for hardware.
5. Biometric Authentication
- Relies on physical traits like fingerprints, facial recognition, or voice patterns.
- Pros: Convenient and secure.
- Cons: Requires specialized hardware.
Choosing the Right MFA Method for Your Application
The type of MFA you implement should be proportional to the sensitivity of the data your application protects and the technical sophistication of your users. There is no single best answer — every method involves trade-offs between security strength, implementation complexity, cost, and user experience.
For Consumer Applications with Broad User Populations
If your users span a wide range of technical ability and device types, TOTP is generally the best starting point. It requires only a smartphone app (Google Authenticator, Authy, Microsoft Authenticator — all of which are free), does not incur per-authentication costs, and is immune to the SIM swap attacks that affect SMS. The setup experience involves scanning a QR code during onboarding, which is familiar enough that most users can complete it without support assistance. When combined with backup codes generated at setup time, TOTP provides a robust baseline that covers the vast majority of password-based attack scenarios.
SMS OTP remains an option for applications where the user base is unlikely to have smartphones with authenticator apps installed, but you should understand and accept the SIM swap risk. If the application handles financial data, health records, or any personally identifiable information covered by GDPR or HIPAA, SMS should be avoided.
For Security-Conscious Users and Developer Tooling
For applications where users are likely to be technically proficient — developer platforms, internal tools, cloud management consoles — WebAuthn represents the best security posture. It eliminates phishing as an attack vector entirely, provides a fast and frictionless login experience once configured, and is natively supported in all modern browsers and operating systems. The primary barrier is that users need a compatible authenticator: this can be a hardware security key (YubiKey, Google Titan), a platform authenticator (Touch ID on Apple devices, Windows Hello on Windows devices), or a passkey synced across devices via iCloud Keychain or Google Password Manager.
For Enterprise and High-Security Environments
Organizations that need to manage authentication centrally — controlling which devices and users can access resources — typically benefit from an integrated identity provider such as Okta, Auth0, or Microsoft Entra ID (formerly Azure AD). These platforms provide:
- Centralized policy enforcement (require stronger MFA for administrators, allow simpler MFA for read-only users)
- Conditional access policies based on device compliance, network location, and other signals
- Detailed audit logs and security event integration with SIEM tooling
- Self-service MFA enrollment and recovery workflows that reduce helpdesk burden
For applications handling payment card data under PCI DSS, hard tokens (hardware security keys or hardware TOTP devices) may be required, as software-based authenticators running on a general-purpose smartphone may not satisfy the auditor’s interpretation of the “something you have” requirement in high-assurance contexts.
Offering Multiple MFA Methods
The most resilient implementations let users register more than one type of MFA factor — for example, a TOTP app as the primary factor and a hardware key as an alternative. This approach reduces account lockout risk (the user can authenticate even if they lose one factor) and allows users to choose the method that best fits their workflow. When offering multiple methods, enforce a clear hierarchy: WebAuthn should be preferred over TOTP, which should be preferred over SMS. Never allow a user to fall back to a weaker method by simply claiming they cannot access the stronger one — that fallback path becomes the target for social engineering.
Implementation Steps for MFA
Step 1: Set Up the Backend
1.1 Install Dependencies
npm init -y
npm install express bcrypt jsonwebtoken speakeasy qrcode
1.2 Generate TOTP Secrets
const speakeasy = require('speakeasy')
const generateSecret = () => {
const secret = speakeasy.generateSecret({ length: 20 })
console.log('Secret:', secret.base32) // Store this securely
return secret
}
1.3 Verify TOTP Tokens
const verifyToken = (token, secret) => {
const isValid = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: token
})
return isValid
}
Step 2: Add MFA During User Registration
- Generate a TOTP secret for the user.
- Display a QR code for the user to scan with an authenticator app.
Example Code:
const qrcode = require('qrcode')
app.post('/register-mfa', async (req, res) => {
const { email, password } = req.body
const hashedPassword = await bcrypt.hash(password, 10)
const secret = speakeasy.generateSecret()
const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url)
// Save user and secret to database
res.json({ qrCodeUrl, secret: secret.base32 })
})
Step 3: Verify MFA During Login
- Authenticate the user’s credentials.
- Prompt for an MFA token after successful authentication.
- Verify the token before granting access.
Example Code:
app.post('/login-mfa', async (req, res) => {
const { email, password, token } = req.body
const user = await User.findOne({ email })
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).send('Invalid credentials')
}
const isTokenValid = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: token
})
if (!isTokenValid) {
return res.status(401).send('Invalid MFA token')
}
const jwtToken = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '1h' })
res.json({ jwtToken })
})
Step 4: Frontend Integration
4.1 Display the QR Code
Use a library like react-qr-code to show the QR code during registration.
import QRCode from 'react-qr-code'
function QrCodeDisplay({ url }) {
return <QRCode value={url} />
}
4.2 MFA Login Form
Prompt users for their MFA token after entering their credentials.
import React, { useState } from 'react'
import axios from 'axios'
function MfaLogin() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
try {
const response = await axios.post('http://localhost:5000/login-mfa', {
email,
password,
token
})
localStorage.setItem('token', response.data.jwtToken)
alert('Login successful')
} catch (error) {
alert('Authentication failed')
}
}
return (
<form onSubmit={handleSubmit}>
<input
type='email'
placeholder='Email'
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type='password'
placeholder='Password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
type='text'
placeholder='MFA Token'
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<button type='submit'>Login</button>
</form>
)
}
export default MfaLogin
Best Practices for MFA
- Backup Codes
- Provide users with backup codes during registration for recovery purposes.
- Rate Limiting
- Limit failed attempts to prevent brute force attacks on MFA tokens.
- Periodic Reauthentication
- Require users to reauthenticate periodically, especially for sensitive actions.
- Educate Users
- Inform users about the importance of MFA and how to secure their accounts.
Step 5: SMS-Based OTP with Twilio
SMS-based one-time passwords are widely deployed because every mobile phone can receive them without requiring an installed app. However, NIST SP 800-63B and OWASP both advise against using SMS for applications that handle personally identifiable information or financial data, because SMS is vulnerable to SIM-swapping attacks and message interception. If you still choose SMS OTP, implement it carefully.
Installing the Twilio SDK
npm install twilio
Generating and Sending an OTP
const twilio = require('twilio')
const crypto = require('crypto')
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
// Generate a 6-digit OTP using a cryptographically secure RNG
function generateOtp() {
const buffer = crypto.randomBytes(3)
const num = parseInt(buffer.toString('hex'), 16) % 1000000
return String(num).padStart(6, '0')
}
async function sendSmsOtp(phoneNumber) {
const otp = generateOtp()
const expiresAt = new Date(Date.now() + 5 * 60 * 1000) // 5-minute TTL
// Store hashed OTP (not plaintext) in your database
const hashedOtp = crypto.createHash('sha256').update(otp).digest('hex')
await db.otpCodes.upsert({
phone: phoneNumber,
code: hashedOtp,
expiresAt,
attempts: 0
})
await client.messages.create({
body: `Your verification code is ${otp}. It expires in 5 minutes.`,
from: process.env.TWILIO_PHONE_NUMBER,
to: phoneNumber
})
}
Verifying the OTP
async function verifySmsOtp(phoneNumber, submittedOtp) {
const record = await db.otpCodes.findOne({ phone: phoneNumber })
if (!record) return { valid: false, reason: 'No OTP found' }
if (new Date() > record.expiresAt) return { valid: false, reason: 'OTP expired' }
if (record.attempts >= 5) return { valid: false, reason: 'Too many attempts' }
const hashedSubmitted = crypto.createHash('sha256').update(submittedOtp).digest('hex')
const isValid = crypto.timingSafeEqual(Buffer.from(hashedSubmitted), Buffer.from(record.code))
if (!isValid) {
await db.otpCodes.update({ phone: phoneNumber }, { attempts: record.attempts + 1 })
return { valid: false, reason: 'Invalid OTP' }
}
// Invalidate after successful use (single-use)
await db.otpCodes.delete({ phone: phoneNumber })
return { valid: true }
}
Key points in this implementation:
crypto.randomBytesensures unpredictable codes — never useMath.random()for security-sensitive values.crypto.timingSafeEqualprevents timing-based side-channel attacks when comparing codes.- The OTP is hashed before storage, reducing exposure if the database is breached.
- A 5-minute TTL and attempt counter prevent brute-force attacks within the code’s validity window.
- OTPs are single-use: the record is deleted on first successful verification so the same code cannot be replayed.
Step 6: Implementing WebAuthn / FIDO2
WebAuthn is the modern, phishing-resistant MFA mechanism. It uses public-key cryptography: during registration, the authenticator (hardware key, Touch ID, Windows Hello) generates a keypair. The private key never leaves the device — only the public key is stored on your server. During login the user proves ownership of the private key by signing a server-issued challenge. Because the credential is scoped to a specific origin, a keypair registered at myapp.example.com cannot be replayed against evil-myapp.example.com, making WebAuthn inherently phishing-resistant.
The @simplewebauthn/server library makes WebAuthn accessible in Node.js / TypeScript without managing the cryptographic primitives yourself.
npm install @simplewebauthn/server @simplewebauthn/browser
Registration Flow (Server Side)
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'
const RP_NAME = 'My Secure App'
const RP_ID = 'myapp.example.com'
const ORIGIN = `https://${RP_ID}`
// Step 1 – Generate registration options and send to client
async function beginRegistration(userId: string, userEmail: string) {
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: Buffer.from(userId),
userName: userEmail,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
}
})
// Persist the challenge in the user's session – never skip this
await sessionStore.set(userId, { challenge: options.challenge })
return options
}
// Step 2 – Verify the response returned by the authenticator
async function finishRegistration(userId: string, body: unknown) {
const { challenge } = await sessionStore.get(userId)
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID
})
if (verification.verified && verification.registrationInfo) {
const { credentialPublicKey, credentialID, counter } = verification.registrationInfo
await db.credentials.create({
userId,
credentialId: Buffer.from(credentialID).toString('base64url'),
publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
counter
})
}
return verification.verified
}
Authentication Flow (Server Side)
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'
async function beginAuthentication(userId: string) {
const userCredentials = await db.credentials.findAll({ userId })
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: 'preferred',
allowCredentials: userCredentials.map((cred) => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key'
}))
})
await sessionStore.set(userId, { challenge: options.challenge })
return options
}
async function finishAuthentication(userId: string, body: unknown) {
const { challenge } = await sessionStore.get(userId)
const credential = await db.credentials.findOne({ credentialId: (body as any).id })
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: Buffer.from(credential.credentialId, 'base64url'),
publicKey: Buffer.from(credential.publicKey, 'base64url'),
counter: credential.counter
}
})
if (verification.verified) {
// Update signature counter to detect cloned authenticators
await db.credentials.update(
{ credentialId: (body as any).id },
{ counter: verification.authenticationInfo.newCounter }
)
}
return verification.verified
}
Client-Side Registration
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
async function registerWebAuthn() {
const options = await fetch('/auth/webauthn/register/begin').then((r) => r.json())
try {
const credential = await startRegistration(options)
const result = await fetch('/auth/webauthn/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential)
}).then((r) => r.json())
if (result.verified) {
alert('Passkey registered successfully!')
}
} catch (err) {
console.error('Registration failed:', err)
}
}
The signature counter stored for each credential is a replay-detection mechanism: each authentication increments the counter, so if the server receives a response with a counter lower than the stored value it knows the credential has been cloned.
MFA in Python
Python developers have excellent library support for MFA. The pyotp library implements RFC 6238 (TOTP) and RFC 4226 (HOTP) and integrates cleanly with Flask, Django, and FastAPI.
pip install pyotp qrcode[pil]
Generating a TOTP Secret and QR Code
import pyotp
import qrcode
import io
import base64
def setup_totp(user_email: str) -> dict:
"""Generate a TOTP secret and QR code for a user."""
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# Build the otpauth URI for authenticator apps
uri = totp.provisioning_uri(name=user_email, issuer_name="My Secure App")
# Generate QR code as a base64-encoded PNG to embed in the setup page
img = qrcode.make(uri)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
return {
"secret": secret, # Encrypt and store in your database
"qr_code": f"data:image/png;base64,{qr_base64}"
}
Verifying a TOTP Token in Python
import pyotp
def verify_totp(secret: str, token: str) -> bool:
"""
Verify a TOTP token. The valid_window=1 parameter allows for a
±30-second clock drift between client and server.
"""
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=1)
FastAPI Integration Example
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
import pyotp
app = FastAPI()
class MfaVerifyRequest(BaseModel):
token: str
@app.post("/mfa/verify")
async def verify_mfa(body: MfaVerifyRequest, user=Depends(get_pending_user)):
token = body.token.strip()
if len(token) != 6 or not token.isdigit():
raise HTTPException(status_code=400, detail="Invalid token format")
if not verify_totp(user.mfa_secret, token):
raise HTTPException(status_code=401, detail="Invalid or expired token")
# Promote to fully authenticated session
return {"success": True, "jwt": issue_jwt(user)}
MFA Library and Provider Comparison
Choosing the right library or service depends on your tech stack, scale, and security requirements.
Node.js / TypeScript Libraries
| Library | MFA Types | Status | Notes |
|---|---|---|---|
speakeasy | TOTP, HOTP | ⚠️ Low activity | Simple API, widely used legacy choice |
otplib | TOTP, HOTP | ✅ Active | Modern, TypeScript-first, RFC-compliant |
@simplewebauthn/server | WebAuthn / Passkeys | ✅ Active | Best choice for FIDO2 |
passport-totp | TOTP | ⚠️ Older | Passport.js integration only |
Python Libraries
| Library | MFA Types | Notes |
|---|---|---|
pyotp | TOTP, HOTP | RFC-compliant, actively maintained |
py_webauthn | WebAuthn | Duo Labs, battle-tested server-side |
django-mfa3 | TOTP, WebAuthn | Django-specific, plug-and-play |
Managed MFA Providers
Managed providers let you offload authentication infrastructure entirely. Consider these when building at scale or when you lack the internal security expertise to review an in-house MFA implementation.
| Provider | Factor Types | Free Tier | Best For |
|---|---|---|---|
| Auth0 | TOTP, SMS, Push, WebAuthn | Yes | SaaS applications |
| Okta | TOTP, SMS, Push, Biometric | No | Enterprise SSO |
| AWS Cognito | TOTP, SMS | Yes | AWS-native workloads |
| Twilio Verify | TOTP, SMS, Voice | Pay-per-use | SMS-heavy workflows |
| Stytch | Passkeys, OTP, Email | Yes | Modern passkey-first apps |
Using a managed provider reduces implementation risk but introduces a third-party dependency. If that provider suffers an outage or breach, every application relying on it is affected. Evaluate the trade-off carefully against the cost of building and maintaining your own implementation.
Compliance and Regulatory Requirements
For many applications, implementing MFA is not optional — it is a legal or contractual obligation. Understanding which regulations apply to your use case can clarify the minimum acceptable MFA configuration and help you prioritize the implementation work.
PCI DSS (Payment Card Industry Data Security Standard)
PCI DSS version 4.0, which all organizations handling card data must comply with, requires MFA for all access into the cardholder data environment (CDE) and for all remote access to the network. This includes not just end-user logins but also system-to-system authentication for administrative interfaces, VPN access, and any privileged account that can modify security controls or access cardholder data. The standard was updated to require MFA for all access to the CDE — not just remote access — as of the March 2024 effective date for new requirements.
GDPR (General Data Protection Regulation)
The GDPR does not explicitly mandate MFA, but Article 32 requires organizations to implement “appropriate technical measures” to protect personal data. Supervisory authorities across the EU have issued fines and enforcement notices citing the absence of MFA as a contributing factor to data breaches. If your application stores data about EU residents, failing to implement MFA for accounts with access to that data creates meaningful regulatory exposure, particularly in the event of a breach.
HIPAA (Health Insurance Portability and Accountability Act)
HIPAA’s Security Rule requires covered entities to implement “reasonable and appropriate safeguards” to protect electronic protected health information (ePHI). While HIPAA does not name MFA by name, the Department of Health and Human Services has consistently identified inadequate access controls — including the absence of secondary authentication — as a common finding in breach investigations. Any application storing or transmitting ePHI should treat MFA as a required control rather than a best practice.
SOC 2 Type II
Service Organization Control 2 audits evaluate security controls across five trust service criteria. MFA is almost universally expected as a control for logical access to systems in scope. Auditors will typically expect MFA to be enforced for administrator accounts, access to production environments, and access to customer data stores. Issuing access credentials without MFA for in-scope systems will generally result in exceptions or qualified opinions in the audit report.
FedRAMP (Federal Risk and Authorization Management Program)
Applications sold to US federal agencies must meet FedRAMP requirements, which mandate MFA for all privileged users and for all network access. Specifically, FedRAMP requires FIDO2 or PIV/CAC-based authentication for high-impact systems — effectively mandating hardware-backed MFA for the most sensitive federal use cases.
Practical Compliance Approach
When designing your MFA implementation, identify all regulatory frameworks that apply to your application and map them to specific requirements: which users must use MFA, which systems are in scope, what minimum factor strength is required, and how MFA enrollment and recovery are audited. Documenting this mapping — and keeping it current as regulations evolve — makes compliance audits significantly smoother.
MFA Authentication Flow Diagrams
TOTP Authentication Sequence
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
participant DB as Database
U->>B: Enter email + password
B->>S: POST /login (credentials)
S->>DB: Fetch user record
DB-->>S: User + hashed password
S->>S: Verify password (bcrypt)
S-->>B: 200 – Password OK, MFA required
B->>U: Show MFA code input
U->>B: Enter 6-digit TOTP code
B->>S: POST /login/mfa (token)
S->>S: Verify TOTP (speakeasy/pyotp)
alt Token valid
S-->>B: 200 – JWT issued
B->>U: Redirect to dashboard
else Token invalid
S-->>B: 401 – Invalid token
B->>U: Show error, increment attempt counter
end
WebAuthn Registration Flow
sequenceDiagram
participant U as User
participant B as Browser
participant A as Authenticator
participant S as Server
U->>B: Click "Register Passkey"
B->>S: GET /auth/webauthn/register/begin
S->>S: Generate challenge + options
S-->>B: PublicKeyCredentialCreationOptions
B->>A: navigator.credentials.create(options)
A->>U: Prompt (biometric / PIN)
U->>A: Verify identity
A->>A: Generate keypair
A-->>B: PublicKeyCredential (public key + attestation)
B->>S: POST /auth/webauthn/register/finish
S->>S: Verify attestation + challenge
S->>S: Store public key + credentialId
S-->>B: 200 – Passkey registered
Adaptive / Risk-Based Authentication Decision Flow
flowchart TD
A[Login Request] --> B{Known device?}
B -- Yes --> C{Trusted location?}
B -- No --> E[Require MFA]
C -- Yes --> D{Normal time of day?}
C -- No --> E
D -- Yes --> F[Allow without MFA prompt]
D -- No --> E
E --> G{MFA passed?}
G -- Yes --> H[Grant access + record device]
G -- No --> I[Block + notify user via email]
Secure OTP Handling and Storage
Secure OTP handling is not just about where you store the secret — it is about maintaining discipline at every layer. Mishandling OTPs has caused real-world account takeovers even in applications that correctly implemented the MFA verification logic.
TOTP Secret Storage
TOTP secrets are long-lived credentials. Treat them like encryption keys, not ordinary user profile data:
- Encrypt at rest: Store secrets encrypted using AES-256-GCM with a key management service (AWS KMS, GCP Cloud KMS, or HashiCorp Vault). Do not use application-level encryption where the key is stored next to the ciphertext.
- Never log secrets: Ensure that secrets never appear in application logs, metrics pipelines, or error tracking tools such as Sentry.
- Isolate in your schema: Store secrets in a separate table or document with restricted access controls, not alongside general user profile data.
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
const ALGORITHM = 'aes-256-gcm'
const KEY = Buffer.from(process.env.MFA_ENCRYPTION_KEY!, 'hex') // 32-byte hex key
export function encryptSecret(plaintext: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv(ALGORITHM, KEY, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`
}
export function decryptSecret(ciphertext: string): string {
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':')
const decipher = createDecipheriv(ALGORITHM, KEY, Buffer.from(ivHex, 'hex'))
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
return Buffer.concat([
decipher.update(Buffer.from(encryptedHex, 'hex')),
decipher.final()
]).toString('utf8')
}
SMS / Email OTP Storage
For short-lived OTPs sent via SMS or email (not TOTP secrets):
- Do not store in plaintext. Hash with SHA-256 before persisting, then compare hashes during verification using
crypto.timingSafeEqual. - Enforce a short TTL of 5–10 minutes maximum.
- Single use only: delete or invalidate the record immediately after successful verification.
- Attempt limits: lock the record after 5 failed verifications within the TTL window.
- On “resend”: issue a new code and overwrite the old record — never allow two valid codes to coexist.
OWASP recommends considering 8-digit OTPs over 6-digit ones where usability allows. An 8-digit code provides 100× more possible values, making brute-force attacks within the validity window far less feasible.
MFA in Microservices and API Architectures
Modern applications are rarely monolithic. If your application is composed of multiple services — a gateway, a user service, a payments service, a notifications service — you need to think carefully about where MFA verification happens and how that assurance is propagated across service boundaries.
Handling MFA at the API Gateway
In a microservices architecture the most practical pattern is to handle MFA verification at the API gateway or authentication service and then encode the authentication assurance level in the token issued to downstream services. When a user completes MFA, the authentication service includes a claim in the JWT indicating the assurance level and the timestamp of the MFA event:
{
"sub": "user_123",
"aal": 2,
"amr": ["pwd", "otp"],
"mfa_at": 1712145600,
"exp": 1712149200
}
aal stands for Authentication Assurance Level (a concept defined in NIST SP 800-63). A value of 1 means password-only; a value of 2 means a second factor was verified. Downstream services can read this claim and enforce minimum assurance levels per operation without issuing a separate authentication check.
Protecting Machine-to-Machine APIs
MFA is a human-facing control. For service-to-service communication, use client credentials (OAuth 2.0 client secret or private key JWT) combined with mutual TLS for transport-layer authentication. Do not attempt to reuse a human user’s MFA session for automated API calls — these should use separate service accounts with scoped permissions and no human-facing authentication flow.
Handling MFA Expiry in Long-Running Workflows
Some workflows — report generation, batch data exports, long-running transactions — take longer than the MFA session lifetime. Design these workflows to capture the user’s authorization at initiation time (after MFA is verified) and execute the work asynchronously without requiring the user to remain authenticated throughout. Store only the minimal scope of authorization needed to complete the workflow, not the full user session.
Audit Logging Across Services
Every service that enforces an assurance level check should emit an audit event: which user, which service, which operation, the assurance level of the session, and the outcome. Aggregate these events in a centralized log store. This gives you the visibility to detect anomalies — such as a session with assurance level 1 attempting to access operations that require assurance level 2 — and to produce the access logs required by compliance frameworks.
Generating and Validating Backup Codes
Backup codes allow users who lose access to their second factor (lost phone, hardware token failure) to regain account access. They must be cryptographically random and single-use so that each code can only be redeemed once.
import { randomBytes, scrypt, timingSafeEqual } from 'crypto'
import { promisify } from 'util'
const scryptAsync = promisify(scrypt)
export async function generateBackupCodes(count = 10): Promise<{
plaintext: string[]
hashed: string[]
}> {
const codes: string[] = []
const hashes: string[] = []
for (let i = 0; i < count; i++) {
// Produce a human-readable 10-character code, e.g. "A3K9-PX2M-LQ"
const raw = randomBytes(7).toString('base64url').toUpperCase().slice(0, 10)
const formatted = `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8)}`
codes.push(formatted)
// Hash for storage using scrypt with a per-code random salt
const salt = randomBytes(16).toString('hex')
const hash = (await scryptAsync(formatted, salt, 32)) as Buffer
hashes.push(`${salt}:${hash.toString('hex')}`)
}
return { plaintext: codes, hashed: hashes }
}
export async function verifyBackupCode(
submitted: string,
storedHashes: string[]
): Promise<{ valid: boolean; usedIndex: number }> {
const normalized = submitted.replace(/\s/g, '').toUpperCase()
for (let i = 0; i < storedHashes.length; i++) {
const [salt, hashHex] = storedHashes[i].split(':')
const storedHash = Buffer.from(hashHex, 'hex')
const derivedHash = (await scryptAsync(normalized, salt, 32)) as Buffer
if (timingSafeEqual(storedHash, derivedHash)) {
return { valid: true, usedIndex: i }
}
}
return { valid: false, usedIndex: -1 }
}
During the MFA setup flow, display the plaintext codes once and encourage the user to store them safely offline (password manager or printed paper). After displaying them, discard the plaintext. Store only the hashed array. When a backup code is redeemed, remove its hash from the stored array so it cannot be used again.
Common Mistakes and Anti-Patterns
Even well-intentioned MFA implementations can introduce serious vulnerabilities. These are the most frequently observed mistakes in production code.
1. Trusting Only the Client-Side Verification
Never verify a TOTP or WebAuthn assertion exclusively on the frontend and trust the result. A JavaScript variable can be manipulated in the browser’s developer console.
// ❌ Anti-pattern: verifying on the client and granting access based on the flag
if (clientSideVerified) {
grantAccess() // An attacker can bypass this by modifying the variable
}
// ✅ Correct: send the raw token to the server and verify there
const response = await fetch('/api/verify-mfa', {
method: 'POST',
body: JSON.stringify({ token })
})
2. Skipping Rate Limiting on the MFA Endpoint
A 6-digit TOTP has only 1,000,000 possible values. Without rate limiting, an attacker who already has the user’s password can exhaust all possibilities within the 30-second window using parallel requests. Apply rate limiting at both the IP level and the per-user level.
import rateLimit from 'express-rate-limit'
const mfaLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 10, // max 10 attempts per IP per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many MFA attempts. Please try again later.' }
})
app.post('/login/mfa', mfaLimiter, verifyMfaHandler)
3. Using a Predictable OTP Generator
Math.random() is not cryptographically secure and must never be used to generate OTPs or any security-sensitive values.
# ❌ Never use this – predictable output
import random
otp = str(random.randint(100000, 999999))
# ✅ Use the secrets module – cryptographically secure
import secrets
otp = str(secrets.randbelow(1000000)).zfill(6)
4. Allowing Replay of TOTP Tokens
A valid TOTP code should be used only once within its 30-second window. Without replay protection, an attacker who intercepts a code during its validity window can reuse it to authenticate. Track used tokens for the duration of their validity:
// Use Redis with a TTL matching the TOTP window in production
const usedTokens = new Map<string, number>()
function verifyTotpNoReplay(secret: string, token: string): boolean {
const key = `${secret}:${token}`
if (usedTokens.has(key)) return false // Replay detected
const valid = speakeasy.totp.verify({ secret, encoding: 'base32', token, window: 1 })
if (valid) {
usedTokens.set(key, Date.now())
}
return valid
}
5. Weak MFA Recovery Paths
Many MFA implementations are bypassed not through the MFA mechanism itself but through the account recovery flow. If a user can disable MFA by clicking “Forgot phone?” and answering a security question, the MFA provides no real security. Recovery flows must require a comparable level of assurance:
- Require redemption of a pre-generated backup code as proof of identity.
- Never allow suspension of MFA via a link sent to the email on file without additional confirmation — an attacker who has the email account also has the reset link.
- Notify users through all enrolled channels whenever MFA settings are changed or an MFA bypass is used.
6. MFA Fatigue Attacks
MFA fatigue (also known as push bombing) targets applications using push-notification MFA (e.g., Duo Mobile, Microsoft Authenticator). An attacker who has the user’s password sends repeated push notifications until the user approves one out of annoyance or confusion. This technique was used against Uber in September 2022 and Cisco in 2022.
Mitigations:
- Require number matching: the user must enter the number displayed on the login screen into the push notification, not just tap “approve”.
- Implement fraud detection that flags a burst of push notifications as suspicious and temporarily blocks further push requests.
- Offer TOTP or WebAuthn as alternatives that are not vulnerable to push bombing.
Testing Your MFA Implementation
MFA logic contains many security-relevant branches: expired tokens, invalid tokens, replay attacks, account lockouts, and more. Testing each deliberately — before deployment — catches the subtle bugs that become vulnerabilities in production.
Unit Testing TOTP Verification (Node.js / Jest)
import speakeasy from 'speakeasy'
import { verifyTotpNoReplay } from './mfa'
describe('TOTP verification', () => {
let secret: string
beforeEach(() => {
secret = speakeasy.generateSecret({ length: 20 }).base32
})
it('accepts a valid token', () => {
const token = speakeasy.totp({ secret, encoding: 'base32' })
expect(verifyTotpNoReplay(secret, token)).toBe(true)
})
it('rejects an invalid token', () => {
expect(verifyTotpNoReplay(secret, '000000')).toBe(false)
})
it('rejects a replayed token within the same window', () => {
const token = speakeasy.totp({ secret, encoding: 'base32' })
expect(verifyTotpNoReplay(secret, token)).toBe(true)
expect(verifyTotpNoReplay(secret, token)).toBe(false) // Replay blocked
})
})
Unit Testing OTP Verification (Python / pytest)
import pyotp
import pytest
from app.mfa import verify_totp
def test_valid_totp():
secret = pyotp.random_base32()
token = pyotp.TOTP(secret).now()
assert verify_totp(secret, token) is True
def test_invalid_totp():
secret = pyotp.random_base32()
assert verify_totp(secret, "000000") is False
def test_expired_totp(monkeypatch):
"""Tokens from more than 60 seconds ago should be rejected."""
import time
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# Simulate a token issued 90 seconds ago
past_time = int(time.time()) - 90
old_token = totp.at(past_time)
# valid_window=1 allows ±30s; a 90-second-old token must fail
assert verify_totp(secret, old_token) is False
Integration Testing the Full MFA Flow
import request from 'supertest'
import app from '../app'
describe('POST /login/mfa', () => {
it('returns 401 when token is absent', async () => {
await request(app)
.post('/login/mfa')
.send({ email: 'test@example.com', password: 'pass' })
.expect(401)
})
it('returns 401 on wrong token', async () => {
await request(app)
.post('/login/mfa')
.send({ email: 'test@example.com', password: 'correct-password', token: '000000' })
.expect(401)
})
it('enforces rate limit after multiple failures', async () => {
for (let i = 0; i < 10; i++) {
await request(app)
.post('/login/mfa')
.send({ email: 'test@example.com', password: 'correct-password', token: '000000' })
}
const res = await request(app)
.post('/login/mfa')
.send({ email: 'test@example.com', password: 'correct-password', token: '000000' })
expect(res.status).toBe(429)
})
})
Security-Focused Test Checklist
Before shipping an MFA implementation, verify every item on this checklist:
- Expired tokens are rejected (outside the ±30-second TOTP window)
- Already-used tokens are rejected within the same window (replay protection)
- Brute force is blocked at the HTTP layer (IP-level rate limit)
- Brute force is blocked per user (account-level lockout after N failures)
- MFA secret never appears in HTTP responses or error messages
- MFA secret never appears in application logs or error tracking
- Account recovery does not silently bypass MFA
- An MFA factor change triggers an out-of-band notification to the user
Adaptive / Risk-Based Authentication
Requiring MFA on every single login frustrates users, especially for low-risk sessions from trusted devices. Risk-based authentication adjusts the authentication requirements dynamically based on the calculated risk of each login attempt, prompting MFA only when the risk score exceeds a threshold.
Signals Used for Risk Scoring
| Signal | Low-Risk Indicator | High-Risk Indicator |
|---|---|---|
| Device fingerprint | Recognized device | New / unknown device |
| IP geolocation | Usual country and city | New country, Tor exit node |
| IP reputation | Clean residential IP | VPN / known malicious IP |
| Time of access | Normal business hours | 3 AM local time |
| Login velocity | 1 attempt | 50 attempts in 5 minutes |
| Credential leak check | Not found in breach data | Found in known breach dataset |
Implementing a Simple Risk Score in Node.js
interface LoginContext {
userId: string
ipAddress: string
userAgent: string
timestamp: Date
}
async function calculateRiskScore(ctx: LoginContext): Promise<number> {
let score = 0
const user = await db.users.findById(ctx.userId)
// Unknown device adds significant risk
const isKnownDevice = user.knownDevices.includes(fingerprintDevice(ctx.userAgent))
if (!isKnownDevice) score += 40
// New country adds substantial risk
const location = await geoLookup(ctx.ipAddress)
if (location.country !== user.usualCountry) score += 30
// Login velocity: check recent attempts with a Redis sliding window
const recentAttempts = await redis.incr(`login:velocity:${ctx.userId}`)
await redis.expire(`login:velocity:${ctx.userId}`, 300) // 5-minute window
if (recentAttempts > 3) score += 20
// Unusual hour adds minor risk
const hour = ctx.timestamp.getUTCHours()
if (hour < 6 || hour > 22) score += 10
return Math.min(score, 100) // Cap at 100
}
async function loginHandler(req: Request, res: Response) {
const { email, password } = req.body
const user = await authenticatePassword(email, password)
if (!user) return res.status(401).json({ error: 'Invalid credentials' })
const riskScore = await calculateRiskScore({
userId: user.id,
ipAddress: req.ip,
userAgent: req.headers['user-agent'] ?? '',
timestamp: new Date()
})
if (riskScore >= 40) {
// Elevated risk: require MFA before granting access
await sessionStore.set(user.id, { requiresMfa: true })
return res.status(202).json({ requiresMfa: true })
}
// Low-risk: issue token without additional MFA prompt
const token = issueJwt(user)
return res.json({ token })
}
This pattern significantly reduces authentication friction for trusted, familiar users while maintaining a high security bar for anomalous logins. Production systems at Google, Microsoft, and major banking institutions all use variants of this approach — sometimes called Continuous Adaptive Risk and Trust Assessment (CARTA).
Session Management After MFA
Completing the MFA challenge successfully is not the end of the authentication story. How you manage the session that follows the MFA step is just as important as the MFA verification itself. Poor session management can allow attackers to reuse intercepted sessions or to leverage a single compromised credential to maintain persistent access long after the original MFA event.
Binding the Session to the MFA Event
When a user completes MFA, your server should record the authentication event in the session with a timestamp and the method used. This allows you to enforce step-up authentication for sensitive operations — requiring the user to re-verify via MFA before they can perform actions like changing their email address, updating payment methods, or exporting data. Never allow a long-lived session to automatically re-elevate privileges without a fresh MFA check when sensitive actions are requested.
Absolute and Sliding Session Expiry
Use both an absolute expiry and a sliding (inactivity) expiry for authenticated sessions:
- Absolute expiry: The session is invalidated after a fixed maximum duration regardless of activity, typically 8–24 hours depending on the sensitivity of the application. This limits the window during which a stolen session token can be replayed.
- Sliding expiry: The session is extended by a fixed interval each time the user makes an authenticated request, but only up to the absolute maximum. Sessions for inactive users expire sooner.
For applications handling financial transactions or sensitive personal data, consider requiring a fresh MFA challenge when the sliding expiry threshold is crossed, rather than simply invalidating the session — this provides continuity for active users while still enforcing regular re-verification.
Detecting Session Anomalies
After MFA, monitor active sessions for anomalies that may indicate session hijacking:
- A sudden change in the client’s IP address or geographic location mid-session.
- A change in the User-Agent string mid-session.
- Unusual activity patterns (sending 500 API requests in 10 seconds when the user historically sends fewer than 10 per minute).
When an anomaly is detected, the correct response is to invalidate the session and require the user to re-authenticate with MFA, not merely to issue a warning. This interrupts session hijacking attacks where the attacker has obtained a valid session token through cross-site scripting, network interception, or browser theft.
Separating Authentication State from Authorization
A common architectural mistake is treating the JWT or session token issued after MFA as proof of both identity and authorization for all operations. Instead, include the authentication assurance level in the token — for example, a claim indicating whether the session was established with password-only, TOTP, or hardware token. Downstream services and authorization checks can then enforce minimum assurance levels per operation without requiring a separate MFA service call on every request.
User Experience and MFA Adoption
The best MFA implementation is one that users actually use. Friction in the enrollment and daily authentication flow is the leading cause of users disabling MFA or choosing not to enable it when it is optional. Thoughtful UX design significantly increases adoption rates and reduces support burden.
Enrollment Experience
Do not bury MFA setup in an account security settings page. The highest-converting enrollment pattern is to prompt users to configure MFA during the onboarding flow itself — ideally on the first login after account creation. Frame MFA enrollment as a protective measure the user is taking for their own benefit, not as a bureaucratic requirement. Provide a clear estimated time (“takes about 2 minutes”) and a visual progress indicator.
When presenting the QR code for TOTP setup, show it at a generous size and offer a text fallback that displays the base32 secret for users whose authenticator app cannot scan QR codes. Validate that the user has successfully configured their authenticator by requiring them to enter one TOTP code before completing enrollment — this eliminates a common support scenario where users claim to have set up MFA but scanned the wrong code.
Communicating Account Security Events
Every meaningful authentication event should generate a user-visible notification:
- A new device was used to log in: email the user with the device type, operating system, and approximate location, with a link to “This wasn’t me” that immediately terminates the session and triggers an MFA reset flow.
- An MFA factor was added or removed: email and, if push is configured, push-notify the user immediately.
- Backup codes were generated or used: notify via all enrolled channels.
These notifications serve dual purposes: they alert legitimate users to unauthorized access attempts, and they build user confidence that the application is actively monitoring their account security — which increases willingness to enable and maintain MFA.
Reducing Friction for Trusted Contexts
Users are more willing to tolerate the MFA step when the application makes clear that trusted devices will be remembered and not challenged on every login. Implement a “remember this device” feature backed by a persistent, secure, randomized device cookie. When the user logs in from a recognized device within the trust period, skip the MFA challenge (or use it only as a soft verification rather than a hard requirement). Be transparent in the UI about how long devices are trusted and how users can revoke trust for specific devices they no longer use.
Real-World Use Cases
Use Case 1: Securing a Financial Application
A banking app implements TOTP-based MFA to protect user accounts from unauthorized transactions.
Use Case 2: Enterprise Applications
An HR management system integrates hardware tokens for secure employee access.
Use Case 3: The 2022 Uber Breach — A Lesson in MFA Fatigue
In September 2022, Uber suffered a significant security breach attributed in part to an MFA fatigue attack. The attacker obtained an employee’s credentials from a purchased credential dump, then bombarded the employee with Duo push notifications for over an hour until the employee approved one. The attacker then impersonated Uber IT support via WhatsApp, convincing the employee that approving the notification would stop the spam.
The lesson: MFA alone is not sufficient if the MFA mechanism is susceptible to social engineering. Mitigations include number-matching challenges in push notifications, contextual details in the push (login location and device name), and anomaly detection on push-send velocity.
Use Case 4: SMS SIM Swap Attacks Against Developer Accounts
Attackers routinely compromise developer accounts on platforms like npm, GitHub, and code hosting services by SIM-swapping the phone number tied to SMS-based 2FA. Once they reroute the SMS OTP to a device they control, they can authenticate as the developer and publish malicious package versions. This is why NIST 800-63B classifies SMS as a “restricted” authenticator and recommends TOTP or WebAuthn for accounts with elevated privileges.
Future Trends in MFA
- Biometric MFA
- Combining biometrics with traditional MFA factors for enhanced security.
- FIDO2 Authentication
- Passwordless authentication using WebAuthn and hardware keys.
- Adaptive MFA
- Dynamically adjusting MFA requirements based on user behavior and context.
-
Passkeys as the Default
The FIDO Alliance, Apple, Google, and Microsoft have all committed to passkeys as the long-term replacement for passwords. Under this model the device acts as the second factor by default — biometrics or a PIN on the device serve as “something you know/are” while device possession serves as “something you have”. Consumer-facing applications are already migrating to passkeys as the primary authentication mechanism, and enterprise adoption is following closely behind.
-
Continuous and Behavioral Authentication
Rather than checking identity only at login, continuous authentication monitors behavior throughout the session — keystroke dynamics, mouse movement patterns, navigation behavior — and triggers step-up authentication if the behavioral profile deviates significantly. This is particularly valuable for preventing session hijacking after the initial login has been authorized.
Conclusion
Adding Multi-Factor Authentication to your app is a crucial step in enhancing security and protecting users from unauthorized access. By following the implementation strategies and best practices outlined in this guide, you can create a secure, user-friendly MFA system. Start integrating MFA today to build trust and ensure the safety of your applications.
The choice of MFA factor matters against your specific threat model: SMS OTP is accessible but susceptible to SIM swapping; TOTP is a solid baseline for most applications; WebAuthn / passkeys offer the strongest protection against phishing and represent the industry’s long-term direction. For most production applications the practical path is to implement TOTP as the baseline, expose WebAuthn registration for users who want phishing-resistant authentication, generate backup codes during onboarding, and layer in risk-based authentication to reduce friction for low-risk sessions.
Remember that MFA does not make an application invulnerable — it must be combined with rate limiting, secure session management, strong password hashing, robust monitoring, and a security-aware account recovery flow. Treat every MFA bypass path — password reset, backup codes, admin override — as carefully as the primary authentication path itself, because attackers will always probe the weakest link. An MFA system is only as strong as its least secure recovery mechanism.