Deep Dive: Multi-Factor Authentication (MFA)
What is Multi-Factor Authentication?
Multi-factor authentication (MFA) is a security mechanism that requires users to verify their identity through two or more independent factors before granting access. The fundamental principle: even if one factor is compromised (e.g., a leaked password), the attacker still cannot access the account without the additional factor(s).
MFA is not a standalone authentication strategy — it’s a security layer added on top of a primary method (typically password-based login). The strength of MFA comes from combining factors from different categories: something you know (password, PIN), something you have (phone, hardware key), or something you are (fingerprint, face). Using two factors from the same category (e.g., a password plus a security question) is not true MFA.
The most common production MFA implementation today is TOTP (Time-based One-Time Password) via authenticator apps like Google Authenticator or Authy, often paired with backup recovery codes. Hardware security keys (FIDO2/U2F) provide the strongest protection and are increasingly adopted for high-value accounts.
Core Principles
- Defense in depth: A single compromised factor doesn’t grant access. Attackers must breach multiple independent channels.
- Factor independence: Each factor must come from a different category (knowledge, possession, inherence) to be effective.
- Risk-proportional: MFA should be enforced based on sensitivity — always for admin accounts and financial operations, optionally for regular users.
- Recovery planning: Every MFA system must include a recovery path for when users lose their second factor.
TOTP Authentication Flow
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
participant DB as Database
participant A as Authenticator App
U->>B: Enter email + password
B->>S: POST /login {email, password}
S->>DB: Validate credentials
DB-->>S: Valid — MFA enabled for this user
S-->>B: 200 {mfaRequired: true, mfaToken: "temp_xyz"}
Note over U,A: User opens authenticator app
A->>A: Generate TOTP: HMAC-SHA1(secret, floor(time/30))
A-->>U: Display 6-digit code: 482951
U->>B: Enter TOTP code: 482951
B->>S: POST /login/mfa {mfaToken: "temp_xyz", code: "482951"}
S->>DB: Retrieve user's TOTP secret
S->>S: Verify: TOTP(secret, time) === "482951"
S->>S: Issue session or JWT
S-->>B: 200 {accessToken, refreshToken} — Fully authenticated
Now let’s explore each authentication factor and implementation approach in detail.
Understanding the Three Factors of Authentication
Authentication factors fall into three categories, each providing a different security guarantee:
1. Knowledge Factors (Something You Know)
Information only the user should know—passwords, PINs, security questions. They’re easy to implement but vulnerable to phishing, social engineering, and brute-force attacks.
Example: Your online banking password.
2. Possession Factors (Something You Have)
Physical or digital items under the user’s control—phones, hardware keys, authenticator apps, or sim cards. Much harder to compromise remotely.
Example: Your phone receiving an SMS code, or a security key.
3. Inherence Factors (Something You Are)
Biometric authentication—fingerprints, face recognition, voice patterns. Unique to each individual and difficult to forge.
Example: Fingerprint unlock on your phone.
Strong MFA typically combines at least two factors from different categories. The most common pattern today is knowledge (password) + possession (authenticator app or hardware key).
TOTP: The Gold Standard of MFA
Time-based One-Time Passwords (TOTP) are the most widely supported form of MFA. Unlike SMS-based codes, TOTP requires the attacker to compromise the user’s device, making it far more secure.
How TOTP Works Under the Hood
TOTP relies on HMAC-SHA1 and synchronized time:
- Shared Secret: During setup, the server generates a random secret (usually 32 bytes) shared with the client
- Time Steps: The current Unix timestamp is divided into 30-second intervals
- HMAC Generation:
HMAC-SHA1(secret, time_step)produces a hash - Code Extraction: The last 6 digits of the hash form the one-time code
Here’s the mathematical process:
T = floor(current_unix_time / 30) // Time step
H = HMAC-SHA1(secret, T) // Hash
code = H[-6:] % 1,000,000 // Extract 6 digits
The beauty is that both server and client can independently verify codes since they share the secret and time.
Implementing TOTP with Speakeasy
Let’s build a complete TOTP implementation:
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
const prisma = new PrismaClient();
// Generate TOTP secret and return QR code
export async function initializeTOTP(userId: string, userEmail: string) {
// Generate a high-entropy secret
const secret = speakeasy.generateSecret({
name: `YourApp (${userEmail})`,
issuer: 'YourApp',
length: 32
});
if (!secret.otpauth_url) {
throw new Error('Failed to generate secret');
}
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url);
// Store temporary secret (not verified yet)
await prisma.mfaSetup.create({
data: {
userId,
secret: secret.base32,
verified: false,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000) // 15 minute window
}
});
return {
secret: secret.base32,
qrCodeUrl: qrCodeDataUrl,
manualEntryKey: secret.base32
};
}
// Verify TOTP code during setup (user must provide a valid code)
export async function verifyTOTPSetup(userId: string, token: string) {
const setup = await prisma.mfaSetup.findFirst({
where: {
userId,
verified: false,
expiresAt: { gt: new Date() }
}
});
if (!setup) {
throw new Error('TOTP setup expired or not found');
}
// Verify with 1-code window tolerance (±1 interval)
const verified = speakeasy.totp.verify({
secret: setup.secret,
encoding: 'base32',
token,
window: 1 // Allow codes from 30s before/after current time
});
if (!verified) {
throw new Error('Invalid TOTP code');
}
// Activate TOTP
await prisma.mfaSetup.update({
where: { id: setup.id },
data: { verified: true }
});
// Update user record
await prisma.user.update({
where: { id: userId },
data: {
totpEnabled: true,
totpSecret: setup.secret
}
});
return { success: true };
}
// Verify TOTP code during login
export async function verifyTOTPLogin(userId: string, token: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user?.totpSecret) {
return false;
}
const verified = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token,
window: 1
});
if (verified) {
// Log MFA verification for audit trail
await prisma.auditLog.create({
data: {
userId,
action: 'MFA_VERIFIED',
ipAddress: '',
userAgent: '',
timestamp: new Date()
}
});
}
return verified;
}// Spring Boot + GoogleAuth (com.warrenstrange:googleauth)
import com.warrenstrange.googleauth.GoogleAuthenticator;
import com.warrenstrange.googleauth.GoogleAuthenticatorKey;
import net.glxn.qrgen.javase.QRCode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
@Service
public class TotpService {
private final GoogleAuthenticator gAuth = new GoogleAuthenticator();
private final MfaSetupRepository mfaSetupRepo;
private final UserRepository userRepo;
private final AuditLogRepository auditLogRepo;
public TotpService(MfaSetupRepository mfaSetupRepo,
UserRepository userRepo,
AuditLogRepository auditLogRepo) {
this.mfaSetupRepo = mfaSetupRepo;
this.userRepo = userRepo;
this.auditLogRepo = auditLogRepo;
}
// Generate TOTP secret and return QR code
@Transactional
public Map<String, String> initializeTOTP(String userId, String userEmail) {
GoogleAuthenticatorKey key = gAuth.createCredentials();
String secret = key.getKey();
String otpauthUrl = String.format(
"otpauth://totp/YourApp%%3A%%20%s?secret=%s&issuer=YourApp",
userEmail, secret
);
// Generate QR code as Base64
byte[] qrBytes = QRCode.from(otpauthUrl).withSize(200, 200).stream().toByteArray();
String qrCodeDataUrl = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrBytes);
// Store temporary secret (not verified yet)
MfaSetup setup = new MfaSetup();
setup.setUserId(userId);
setup.setSecret(secret);
setup.setVerified(false);
setup.setCreatedAt(Instant.now());
setup.setExpiresAt(Instant.now().plusSeconds(15 * 60)); // 15-minute window
mfaSetupRepo.save(setup);
return Map.of(
"secret", secret,
"qrCodeUrl", qrCodeDataUrl,
"manualEntryKey", secret
);
}
// Verify TOTP code during setup
@Transactional
public void verifyTOTPSetup(String userId, int token) {
MfaSetup setup = mfaSetupRepo
.findFirstByUserIdAndVerifiedFalseAndExpiresAtAfter(userId, Instant.now())
.orElseThrow(() -> new RuntimeException("TOTP setup expired or not found"));
boolean verified = gAuth.authorize(setup.getSecret(), token);
if (!verified) {
throw new RuntimeException("Invalid TOTP code");
}
setup.setVerified(true);
mfaSetupRepo.save(setup);
User user = userRepo.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
user.setTotpEnabled(true);
user.setTotpSecret(setup.getSecret());
userRepo.save(user);
}
// Verify TOTP code during login
@Transactional
public boolean verifyTOTPLogin(String userId, int token) {
User user = userRepo.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
if (user.getTotpSecret() == null) return false;
boolean verified = gAuth.authorize(user.getTotpSecret(), token);
if (verified) {
AuditLog log = new AuditLog();
log.setUserId(userId);
log.setAction("MFA_VERIFIED");
log.setIpAddress("");
log.setUserAgent("");
log.setTimestamp(Instant.now());
auditLogRepo.save(log);
}
return verified;
}
}# FastAPI + pyotp + qrcode
import pyotp
import qrcode
import io
import base64
import hashlib
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from models import MfaSetup, User, AuditLog
def initialize_totp(user_id: str, user_email: str, db: Session) -> dict:
# Generate a high-entropy secret
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
otpauth_url = totp.provisioning_uri(name=user_email, issuer_name="YourApp")
# Generate QR code as data URL
img = qrcode.make(otpauth_url)
buf = io.BytesIO()
img.save(buf, format="PNG")
qr_code_data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
# Store temporary secret (not verified yet)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
setup = MfaSetup(
user_id=user_id,
secret=secret,
verified=False,
created_at=datetime.now(timezone.utc),
expires_at=expires_at,
)
db.add(setup)
db.commit()
return {"secret": secret, "qr_code_url": qr_code_data_url, "manual_entry_key": secret}
def verify_totp_setup(user_id: str, token: str, db: Session) -> dict:
setup = (
db.query(MfaSetup)
.filter(
MfaSetup.user_id == user_id,
MfaSetup.verified == False,
MfaSetup.expires_at > datetime.now(timezone.utc),
)
.first()
)
if not setup:
raise ValueError("TOTP setup expired or not found")
totp = pyotp.TOTP(setup.secret)
# valid_window=1 allows ±1 interval tolerance
if not totp.verify(token, valid_window=1):
raise ValueError("Invalid TOTP code")
setup.verified = True
user = db.query(User).filter(User.id == user_id).first()
user.totp_enabled = True
user.totp_secret = setup.secret
db.commit()
return {"success": True}
def verify_totp_login(user_id: str, token: str, db: Session) -> bool:
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.totp_secret:
return False
totp = pyotp.TOTP(user.totp_secret)
verified = totp.verify(token, valid_window=1)
if verified:
log = AuditLog(
user_id=user_id,
action="MFA_VERIFIED",
ip_address="",
user_agent="",
timestamp=datetime.now(timezone.utc),
)
db.add(log)
db.commit()
return verified// ASP.NET Core + OtpNet + QRCoder
using OtpNet;
using QRCoder;
using System.Security.Cryptography;
public class TotpService
{
private readonly AppDbContext _db;
public TotpService(AppDbContext db) => _db = db;
// Generate TOTP secret and return QR code
public async Task<TotpSetupResult> InitializeTotpAsync(string userId, string userEmail)
{
// Generate a high-entropy secret (20 bytes = 160 bits)
var secretBytes = RandomNumberGenerator.GetBytes(20);
var secret = Base32Encoding.ToString(secretBytes);
var otpauthUrl = $"otpauth://totp/YourApp%3A{Uri.EscapeDataString(userEmail)}" +
$"?secret={secret}&issuer=YourApp";
// Generate QR code as data URL
using var qrGenerator = new QRCodeGenerator();
using var qrData = qrGenerator.CreateQrCode(otpauthUrl, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrData);
var qrBytes = qrCode.GetGraphic(5);
var qrCodeDataUrl = "data:image/png;base64," + Convert.ToBase64String(qrBytes);
// Store temporary secret (not verified yet)
var setup = new MfaSetup
{
UserId = userId,
Secret = secret,
Verified = false,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddMinutes(15)
};
_db.MfaSetups.Add(setup);
await _db.SaveChangesAsync();
return new TotpSetupResult
{
Secret = secret,
QrCodeUrl = qrCodeDataUrl,
ManualEntryKey = secret
};
}
// Verify TOTP code during setup
public async Task VerifyTotpSetupAsync(string userId, string token)
{
var setup = await _db.MfaSetups
.Where(s => s.UserId == userId && !s.Verified && s.ExpiresAt > DateTime.UtcNow)
.FirstOrDefaultAsync()
?? throw new InvalidOperationException("TOTP setup expired or not found");
var secretBytes = Base32Encoding.ToBytes(setup.Secret);
var totp = new Totp(secretBytes);
// VerifyTotp with window of 1 (±1 interval)
bool verified = totp.VerifyTotp(token, out _, new VerificationWindow(1, 1));
if (!verified)
throw new InvalidOperationException("Invalid TOTP code");
setup.Verified = true;
var user = await _db.Users.FindAsync(userId)
?? throw new InvalidOperationException("User not found");
user.TotpEnabled = true;
user.TotpSecret = setup.Secret;
await _db.SaveChangesAsync();
}
// Verify TOTP code during login
public async Task<bool> VerifyTotpLoginAsync(string userId, string token)
{
var user = await _db.Users.FindAsync(userId);
if (user?.TotpSecret == null) return false;
var secretBytes = Base32Encoding.ToBytes(user.TotpSecret);
var totp = new Totp(secretBytes);
bool verified = totp.VerifyTotp(token, out _, new VerificationWindow(1, 1));
if (verified)
{
_db.AuditLogs.Add(new AuditLog
{
UserId = userId,
Action = "MFA_VERIFIED",
IpAddress = "",
UserAgent = "",
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
return verified;
}
}Integration with Express.js
import express, { Request, Response } from 'express';
import { authenticateUser } from './auth';
const app = express();
// Step 1: User initiates TOTP setup
app.post('/api/mfa/totp/setup', async (req: Request, res: Response) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { secret, qrCodeUrl, manualEntryKey } = await initializeTOTP(
userId,
req.user.email
);
res.json({
secret: manualEntryKey, // For manual entry
qrCode: qrCodeUrl,
message: 'Scan the QR code with your authenticator app'
});
} catch (error) {
res.status(500).json({ error: 'Failed to initialize TOTP' });
}
});
// Step 2: User confirms TOTP setup with a valid code
app.post('/api/mfa/totp/verify-setup', async (req: Request, res: Response) => {
try {
const userId = req.user?.id;
const { token } = req.body;
if (!userId || !token) {
return res.status(400).json({ error: 'Missing required fields' });
}
await verifyTOTPSetup(userId, token);
res.json({ success: true, message: 'TOTP enabled successfully' });
} catch (error) {
res.status(400).json({ error: 'Invalid code or setup expired' });
}
});
// Step 3: Verify TOTP during login
app.post('/api/auth/verify-mfa', async (req: Request, res: Response) => {
try {
const { userId, token } = req.body;
const valid = await verifyTOTPLogin(userId, token);
if (!valid) {
return res.status(401).json({ error: 'Invalid TOTP code' });
}
// Issue session/JWT after MFA verification
const session = await createSession(userId);
res.json({ sessionToken: session.token });
} catch (error) {
res.status(500).json({ error: 'MFA verification failed' });
}
});// Spring Boot REST Controller
@RestController
@RequestMapping("/api")
public class MfaController {
private final TotpService totpService;
private final SessionService sessionService;
public MfaController(TotpService totpService, SessionService sessionService) {
this.totpService = totpService;
this.sessionService = sessionService;
}
// Step 1: User initiates TOTP setup
@PostMapping("/mfa/totp/setup")
public ResponseEntity<?> setupTotp(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) {
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
}
try {
var result = totpService.initializeTOTP(principal.getId(), principal.getEmail());
return ResponseEntity.ok(Map.of(
"secret", result.get("manualEntryKey"),
"qrCode", result.get("qrCodeUrl"),
"message", "Scan the QR code with your authenticator app"
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "Failed to initialize TOTP"));
}
}
// Step 2: User confirms TOTP setup with a valid code
@PostMapping("/mfa/totp/verify-setup")
public ResponseEntity<?> verifyTotpSetup(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
if (principal == null || body.get("token") == null) {
return ResponseEntity.status(400).body(Map.of("error", "Missing required fields"));
}
try {
int token = Integer.parseInt(body.get("token"));
totpService.verifyTOTPSetup(principal.getId(), token);
return ResponseEntity.ok(Map.of("success", true, "message", "TOTP enabled successfully"));
} catch (Exception e) {
return ResponseEntity.status(400).body(Map.of("error", "Invalid code or setup expired"));
}
}
// Step 3: Verify TOTP during login
@PostMapping("/auth/verify-mfa")
public ResponseEntity<?> verifyMfa(@RequestBody Map<String, String> body) {
try {
String userId = body.get("userId");
int token = Integer.parseInt(body.get("token"));
boolean valid = totpService.verifyTOTPLogin(userId, token);
if (!valid) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid TOTP code"));
}
var session = sessionService.createSession(userId);
return ResponseEntity.ok(Map.of("sessionToken", session.getToken()));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "MFA verification failed"));
}
}
}# FastAPI router
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
router = APIRouter()
class TokenBody(BaseModel):
token: str
class VerifyMfaBody(BaseModel):
user_id: str
token: str
# Step 1: User initiates TOTP setup
@router.post("/mfa/totp/setup")
async def setup_totp(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
result = initialize_totp(current_user.id, current_user.email, db)
return {
"secret": result["manual_entry_key"],
"qr_code": result["qr_code_url"],
"message": "Scan the QR code with your authenticator app",
}
# Step 2: User confirms TOTP setup with a valid code
@router.post("/mfa/totp/verify-setup")
async def verify_totp_setup_endpoint(
body: TokenBody,
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
result = verify_totp_setup(current_user.id, body.token, db)
return {"success": True, "message": "TOTP enabled successfully"}
except ValueError:
raise HTTPException(status_code=400, detail="Invalid code or setup expired")
# Step 3: Verify TOTP during login
@router.post("/auth/verify-mfa")
async def verify_mfa(body: VerifyMfaBody, db: Session = Depends(get_db)):
valid = verify_totp_login(body.user_id, body.token, db)
if not valid:
raise HTTPException(status_code=401, detail="Invalid TOTP code")
session = create_session(body.user_id)
return {"session_token": session.token}// ASP.NET Core Controller
[ApiController]
[Route("api")]
public class MfaController : ControllerBase
{
private readonly TotpService _totpService;
private readonly SessionService _sessionService;
public MfaController(TotpService totpService, SessionService sessionService)
{
_totpService = totpService;
_sessionService = sessionService;
}
// Step 1: User initiates TOTP setup
[HttpPost("mfa/totp/setup")]
[Authorize]
public async Task<IActionResult> SetupTotp()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var email = User.FindFirstValue(ClaimTypes.Email);
try
{
var result = await _totpService.InitializeTotpAsync(userId!, email!);
return Ok(new
{
secret = result.ManualEntryKey,
qrCode = result.QrCodeUrl,
message = "Scan the QR code with your authenticator app"
});
}
catch
{
return StatusCode(500, new { error = "Failed to initialize TOTP" });
}
}
// Step 2: User confirms TOTP setup with a valid code
[HttpPost("mfa/totp/verify-setup")]
[Authorize]
public async Task<IActionResult> VerifyTotpSetup([FromBody] TokenRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null || request.Token == null)
return BadRequest(new { error = "Missing required fields" });
try
{
await _totpService.VerifyTotpSetupAsync(userId, request.Token);
return Ok(new { success = true, message = "TOTP enabled successfully" });
}
catch
{
return BadRequest(new { error = "Invalid code or setup expired" });
}
}
// Step 3: Verify TOTP during login
[HttpPost("auth/verify-mfa")]
public async Task<IActionResult> VerifyMfa([FromBody] VerifyMfaRequest request)
{
try
{
var valid = await _totpService.VerifyTotpLoginAsync(request.UserId, request.Token);
if (!valid)
return Unauthorized(new { error = "Invalid TOTP code" });
var session = await _sessionService.CreateSessionAsync(request.UserId);
return Ok(new { sessionToken = session.Token });
}
catch
{
return StatusCode(500, new { error = "MFA verification failed" });
}
}
}Backup and Recovery Codes
What happens when a user loses their phone? Backup codes provide a recovery path without compromising security.
Generating and Storing Backup Codes
export async function generateBackupCodes(userId: string, count: number = 10) {
const codes = Array.from({ length: count }).map(() => {
// Generate 8-character alphanumeric codes
return crypto
.randomBytes(6)
.toString('hex')
.substring(0, 8)
.toUpperCase();
});
// Hash codes before storage (they should be one-way)
const hashedCodes = codes.map(code => ({
hash: crypto.createHash('sha256').update(code).digest('hex'),
used: false,
createdAt: new Date(),
userId
}));
// Store hashed codes
await prisma.backupCode.createMany({
data: hashedCodes
});
// Return unhashed codes for display (only time user sees them)
return codes;
}
// Verify and consume a backup code
export async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = crypto.createHash('sha256').update(code).digest('hex');
const backupCode = await prisma.backupCode.findFirst({
where: {
userId,
hash: codeHash,
used: false
}
});
if (!backupCode) {
return false;
}
// Mark as used (one-time only)
await prisma.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() }
});
// Log usage for audit trail
await prisma.auditLog.create({
data: {
userId,
action: 'BACKUP_CODE_USED',
timestamp: new Date()
}
});
return true;
}
// Check if user has remaining backup codes
export async function getBackupCodeCount(userId: string): Promise<number> {
return prisma.backupCode.count({
where: {
userId,
used: false
}
});
}@Service
public class BackupCodeService {
private final BackupCodeRepository backupCodeRepo;
private final AuditLogRepository auditLogRepo;
public BackupCodeService(BackupCodeRepository backupCodeRepo,
AuditLogRepository auditLogRepo) {
this.backupCodeRepo = backupCodeRepo;
this.auditLogRepo = auditLogRepo;
}
@Transactional
public List<String> generateBackupCodes(String userId, int count) {
List<String> codes = new ArrayList<>();
List<BackupCode> entities = new ArrayList<>();
for (int i = 0; i < count; i++) {
// Generate 8-character hex code
byte[] bytes = new byte[6];
new SecureRandom().nextBytes(bytes);
String code = HexFormat.of().formatHex(bytes).substring(0, 8).toUpperCase();
codes.add(code);
String hash = sha256Hex(code);
BackupCode entity = new BackupCode();
entity.setUserId(userId);
entity.setHash(hash);
entity.setUsed(false);
entity.setCreatedAt(Instant.now());
entities.add(entity);
}
backupCodeRepo.saveAll(entities);
return codes; // Return plaintext codes for one-time display
}
@Transactional
public boolean verifyBackupCode(String userId, String code) {
String codeHash = sha256Hex(code);
Optional<BackupCode> backupCode = backupCodeRepo
.findFirstByUserIdAndHashAndUsedFalse(userId, codeHash);
if (backupCode.isEmpty()) return false;
BackupCode bc = backupCode.get();
bc.setUsed(true);
bc.setUsedAt(Instant.now());
backupCodeRepo.save(bc);
AuditLog log = new AuditLog();
log.setUserId(userId);
log.setAction("BACKUP_CODE_USED");
log.setTimestamp(Instant.now());
auditLogRepo.save(log);
return true;
}
public long getBackupCodeCount(String userId) {
return backupCodeRepo.countByUserIdAndUsedFalse(userId);
}
private String sha256Hex(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}import secrets
import hashlib
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from models import BackupCode, AuditLog
def generate_backup_codes(user_id: str, db: Session, count: int = 10) -> list[str]:
codes = []
entities = []
for _ in range(count):
# Generate 8-character hex code
code = secrets.token_hex(6)[:8].upper()
codes.append(code)
code_hash = hashlib.sha256(code.encode()).hexdigest()
entities.append(BackupCode(
user_id=user_id,
hash=code_hash,
used=False,
created_at=datetime.now(timezone.utc),
))
db.add_all(entities)
db.commit()
return codes # Return plaintext codes for one-time display
def verify_backup_code(user_id: str, code: str, db: Session) -> bool:
code_hash = hashlib.sha256(code.encode()).hexdigest()
backup_code = (
db.query(BackupCode)
.filter(
BackupCode.user_id == user_id,
BackupCode.hash == code_hash,
BackupCode.used == False,
)
.first()
)
if not backup_code:
return False
backup_code.used = True
backup_code.used_at = datetime.now(timezone.utc)
log = AuditLog(
user_id=user_id,
action="BACKUP_CODE_USED",
timestamp=datetime.now(timezone.utc),
)
db.add(log)
db.commit()
return True
def get_backup_code_count(user_id: str, db: Session) -> int:
return db.query(BackupCode).filter(
BackupCode.user_id == user_id,
BackupCode.used == False,
).count()public class BackupCodeService
{
private readonly AppDbContext _db;
public BackupCodeService(AppDbContext db) => _db = db;
public async Task<List<string>> GenerateBackupCodesAsync(string userId, int count = 10)
{
var codes = new List<string>();
var entities = new List<BackupCode>();
for (int i = 0; i < count; i++)
{
// Generate 8-character hex code
var bytes = RandomNumberGenerator.GetBytes(6);
var code = Convert.ToHexString(bytes)[..8].ToUpper();
codes.Add(code);
entities.Add(new BackupCode
{
UserId = userId,
Hash = Sha256Hex(code),
Used = false,
CreatedAt = DateTime.UtcNow
});
}
_db.BackupCodes.AddRange(entities);
await _db.SaveChangesAsync();
return codes; // Return plaintext codes for one-time display
}
public async Task<bool> VerifyBackupCodeAsync(string userId, string code)
{
var codeHash = Sha256Hex(code);
var backupCode = await _db.BackupCodes
.Where(bc => bc.UserId == userId && bc.Hash == codeHash && !bc.Used)
.FirstOrDefaultAsync();
if (backupCode == null) return false;
backupCode.Used = true;
backupCode.UsedAt = DateTime.UtcNow;
_db.AuditLogs.Add(new AuditLog
{
UserId = userId,
Action = "BACKUP_CODE_USED",
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
return true;
}
public async Task<int> GetBackupCodeCountAsync(string userId) =>
await _db.BackupCodes.CountAsync(bc => bc.UserId == userId && !bc.Used);
private static string Sha256Hex(string input)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLower();
}
}Endpoint for displaying backup codes during setup
app.post('/api/mfa/backup-codes/generate', async (req: Request, res: Response) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const codes = await generateBackupCodes(userId);
res.json({
codes,
message: 'Save these codes in a secure location. Each code can only be used once.'
});
} catch (error) {
res.status(500).json({ error: 'Failed to generate backup codes' });
}
});
// Verify backup code during login
app.post('/api/auth/verify-backup-code', async (req: Request, res: Response) => {
try {
const { userId, code } = req.body;
const valid = await verifyBackupCode(userId, code);
if (!valid) {
return res.status(401).json({ error: 'Invalid or expired backup code' });
}
const session = await createSession(userId);
res.json({ sessionToken: session.token });
} catch (error) {
res.status(500).json({ error: 'Backup code verification failed' });
}
});@RestController
@RequestMapping("/api")
public class BackupCodeController {
private final BackupCodeService backupCodeService;
private final SessionService sessionService;
public BackupCodeController(BackupCodeService backupCodeService,
SessionService sessionService) {
this.backupCodeService = backupCodeService;
this.sessionService = sessionService;
}
@PostMapping("/mfa/backup-codes/generate")
@Authorize
public ResponseEntity<?> generateBackupCodes(
@AuthenticationPrincipal UserPrincipal principal) {
try {
List<String> codes = backupCodeService.generateBackupCodes(principal.getId(), 10);
return ResponseEntity.ok(Map.of(
"codes", codes,
"message", "Save these codes in a secure location. Each code can only be used once."
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "Failed to generate backup codes"));
}
}
@PostMapping("/auth/verify-backup-code")
public ResponseEntity<?> verifyBackupCode(@RequestBody Map<String, String> body) {
try {
String userId = body.get("userId");
String code = body.get("code");
boolean valid = backupCodeService.verifyBackupCode(userId, code);
if (!valid) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or expired backup code"));
}
var session = sessionService.createSession(userId);
return ResponseEntity.ok(Map.of("sessionToken", session.getToken()));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "Backup code verification failed"));
}
}
}from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
router = APIRouter()
class VerifyBackupCodeBody(BaseModel):
user_id: str
code: str
@router.post("/mfa/backup-codes/generate")
async def generate_backup_codes_endpoint(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
codes = generate_backup_codes(current_user.id, db)
return {
"codes": codes,
"message": "Save these codes in a secure location. Each code can only be used once.",
}
@router.post("/auth/verify-backup-code")
async def verify_backup_code_endpoint(
body: VerifyBackupCodeBody,
db: Session = Depends(get_db),
):
valid = verify_backup_code(body.user_id, body.code, db)
if not valid:
raise HTTPException(status_code=401, detail="Invalid or expired backup code")
session = create_session(body.user_id)
return {"session_token": session.token}[ApiController]
[Route("api")]
public class BackupCodeController : ControllerBase
{
private readonly BackupCodeService _backupCodeService;
private readonly SessionService _sessionService;
public BackupCodeController(BackupCodeService backupCodeService,
SessionService sessionService)
{
_backupCodeService = backupCodeService;
_sessionService = sessionService;
}
[HttpPost("mfa/backup-codes/generate")]
[Authorize]
public async Task<IActionResult> GenerateBackupCodes()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var codes = await _backupCodeService.GenerateBackupCodesAsync(userId);
return Ok(new
{
codes,
message = "Save these codes in a secure location. Each code can only be used once."
});
}
[HttpPost("auth/verify-backup-code")]
public async Task<IActionResult> VerifyBackupCode([FromBody] VerifyBackupCodeRequest request)
{
var valid = await _backupCodeService.VerifyBackupCodeAsync(request.UserId, request.Code);
if (!valid)
return Unauthorized(new { error = "Invalid or expired backup code" });
var session = await _sessionService.CreateSessionAsync(request.UserId);
return Ok(new { sessionToken = session.Token });
}
}SMS-Based MFA (Legacy but Necessary)
While SMS is vulnerable to SIM swapping and interception, it remains widely supported. Use it as a fallback, never as your primary MFA method.
Implementation with Rate Limiting
import twilio from 'twilio';
import { RateLimiter } from 'bottleneck';
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
// Rate limiter: max 3 SMS per phone number per hour
const smsLimiter = new RateLimiter({
maxConcurrent: 1,
minTime: 1000,
reservoir: 3,
reservoirRefreshAmount: 3,
reservoirRefreshInterval: 60 * 60 * 1000
});
export async function sendSMSCode(userId: string, phoneNumber: string) {
try {
// Rate limiting check
await smsLimiter.schedule(async () => {
const code = crypto.randomInt(100000, 999999).toString();
// Store code with expiration (5 minutes)
await prisma.smsChallenge.create({
data: {
userId,
code,
phoneNumber: maskPhoneNumber(phoneNumber),
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
attempts: 0
}
});
// Send SMS
await twilioClient.messages.create({
body: `Your authentication code is: ${code}. Valid for 5 minutes.`,
from: process.env.TWILIO_PHONE_NUMBER,
to: phoneNumber
});
return { success: true };
});
} catch (error) {
if (error instanceof Error && error.message.includes('Rate limit exceeded')) {
throw new Error('Too many SMS requests. Please try again later.');
}
throw error;
}
}
export async function verifySMSCode(userId: string, code: string): Promise<boolean> {
const challenge = await prisma.smsChallenge.findFirst({
where: {
userId,
expiresAt: { gt: new Date() },
attempts: { lt: 5 } // Max 5 attempts
},
orderBy: { createdAt: 'desc' }
});
if (!challenge) {
return false;
}
// Increment attempts
await prisma.smsChallenge.update({
where: { id: challenge.id },
data: { attempts: challenge.attempts + 1 }
});
// Verify code (timing-safe comparison to prevent timing attacks)
const isValid = crypto.timingSafeEqual(
Buffer.from(challenge.code),
Buffer.from(code)
);
if (isValid) {
// Mark as used
await prisma.smsChallenge.update({
where: { id: challenge.id },
data: { verifiedAt: new Date() }
});
}
return isValid;
}
function maskPhoneNumber(phone: string): string {
return phone.replace(/\d(?=\d{4})/g, '*');
}// Spring Boot + Twilio SDK + Bucket4j rate limiting
import com.twilio.Twilio;
import com.twilio.rest.api.v2010.account.Message;
import com.twilio.type.PhoneNumber;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import java.time.Duration;
@Service
public class SmsService {
private final SmsChallengeRepository smsChallengeRepo;
private final Map<String, Bucket> rateLimitBuckets = new ConcurrentHashMap<>();
public SmsService(SmsChallengeRepository smsChallengeRepo) {
this.smsChallengeRepo = smsChallengeRepo;
Twilio.init(System.getenv("TWILIO_ACCOUNT_SID"), System.getenv("TWILIO_AUTH_TOKEN"));
}
// Rate limiter: max 3 SMS per userId per hour
private Bucket getBucketForUser(String userId) {
return rateLimitBuckets.computeIfAbsent(userId, k ->
Bucket.builder()
.addLimit(Bandwidth.simple(3, Duration.ofHours(1)))
.build()
);
}
@Transactional
public void sendSmsCode(String userId, String phoneNumber) {
Bucket bucket = getBucketForUser(userId);
if (!bucket.tryConsume(1)) {
throw new RuntimeException("Too many SMS requests. Please try again later.");
}
// 6-digit code
String code = String.format("%06d", new SecureRandom().nextInt(900000) + 100000);
SmsChallenge challenge = new SmsChallenge();
challenge.setUserId(userId);
challenge.setCode(code);
challenge.setPhoneNumber(maskPhoneNumber(phoneNumber));
challenge.setExpiresAt(Instant.now().plusSeconds(5 * 60));
challenge.setAttempts(0);
smsChallengeRepo.save(challenge);
Message.creator(
new PhoneNumber(phoneNumber),
new PhoneNumber(System.getenv("TWILIO_PHONE_NUMBER")),
"Your authentication code is: " + code + ". Valid for 5 minutes."
).create();
}
@Transactional
public boolean verifySmsCode(String userId, String code) {
SmsChallenge challenge = smsChallengeRepo
.findFirstByUserIdAndExpiresAtAfterAndAttemptsLessThan(
userId, Instant.now(), 5)
.orElse(null);
if (challenge == null) return false;
challenge.setAttempts(challenge.getAttempts() + 1);
smsChallengeRepo.save(challenge);
// Timing-safe comparison
boolean isValid = MessageDigest.isEqual(
challenge.getCode().getBytes(StandardCharsets.UTF_8),
code.getBytes(StandardCharsets.UTF_8)
);
if (isValid) {
challenge.setVerifiedAt(Instant.now());
smsChallengeRepo.save(challenge);
}
return isValid;
}
private String maskPhoneNumber(String phone) {
return phone.replaceAll("\\d(?=\\d{4})", "*");
}
}# FastAPI + Twilio + slowapi rate limiting
from twilio.rest import Client as TwilioClient
from datetime import datetime, timedelta, timezone
import secrets
import hmac
from sqlalchemy.orm import Session
from models import SmsChallenge
twilio_client = TwilioClient(
os.environ["TWILIO_ACCOUNT_SID"],
os.environ["TWILIO_AUTH_TOKEN"],
)
# Simple in-memory rate limiter (use Redis in production)
_sms_rate_limits: dict[str, list[datetime]] = {}
def _check_rate_limit(user_id: str, max_count: int = 3, window_hours: int = 1) -> bool:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=window_hours)
times = [t for t in _sms_rate_limits.get(user_id, []) if t > cutoff]
if len(times) >= max_count:
return False
times.append(now)
_sms_rate_limits[user_id] = times
return True
def send_sms_code(user_id: str, phone_number: str, db: Session) -> None:
if not _check_rate_limit(user_id):
raise ValueError("Too many SMS requests. Please try again later.")
code = str(secrets.randbelow(900000) + 100000) # 6-digit code
challenge = SmsChallenge(
user_id=user_id,
code=code,
phone_number=mask_phone_number(phone_number),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
attempts=0,
)
db.add(challenge)
db.commit()
twilio_client.messages.create(
body=f"Your authentication code is: {code}. Valid for 5 minutes.",
from_=os.environ["TWILIO_PHONE_NUMBER"],
to=phone_number,
)
def verify_sms_code(user_id: str, code: str, db: Session) -> bool:
challenge = (
db.query(SmsChallenge)
.filter(
SmsChallenge.user_id == user_id,
SmsChallenge.expires_at > datetime.now(timezone.utc),
SmsChallenge.attempts < 5,
)
.order_by(SmsChallenge.created_at.desc())
.first()
)
if not challenge:
return False
challenge.attempts += 1
# Timing-safe comparison
is_valid = hmac.compare_digest(challenge.code.encode(), code.encode())
if is_valid:
challenge.verified_at = datetime.now(timezone.utc)
db.commit()
return is_valid
def mask_phone_number(phone: str) -> str:
import re
return re.sub(r'\d(?=\d{4})', '*', phone)// ASP.NET Core + Twilio + in-process rate limiter
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
using System.Security.Cryptography;
using System.Collections.Concurrent;
public class SmsService
{
private readonly AppDbContext _db;
private readonly ConcurrentDictionary<string, List<DateTime>> _rateLimits = new();
public SmsService(AppDbContext db)
{
_db = db;
TwilioClient.Init(
Environment.GetEnvironmentVariable("TWILIO_ACCOUNT_SID"),
Environment.GetEnvironmentVariable("TWILIO_AUTH_TOKEN")
);
}
private bool CheckRateLimit(string userId, int maxCount = 3, int windowHours = 1)
{
var now = DateTime.UtcNow;
var cutoff = now.AddHours(-windowHours);
var times = _rateLimits.GetOrAdd(userId, _ => new List<DateTime>());
lock (times)
{
times.RemoveAll(t => t < cutoff);
if (times.Count >= maxCount) return false;
times.Add(now);
}
return true;
}
public async Task SendSmsCodeAsync(string userId, string phoneNumber)
{
if (!CheckRateLimit(userId))
throw new InvalidOperationException("Too many SMS requests. Please try again later.");
// 6-digit code
var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();
_db.SmsChallenges.Add(new SmsChallenge
{
UserId = userId,
Code = code,
PhoneNumber = MaskPhoneNumber(phoneNumber),
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
Attempts = 0
});
await _db.SaveChangesAsync();
await MessageResource.CreateAsync(
body: $"Your authentication code is: {code}. Valid for 5 minutes.",
from: new PhoneNumber(Environment.GetEnvironmentVariable("TWILIO_PHONE_NUMBER")),
to: new PhoneNumber(phoneNumber)
);
}
public async Task<bool> VerifySmsCodeAsync(string userId, string code)
{
var challenge = await _db.SmsChallenges
.Where(c => c.UserId == userId
&& c.ExpiresAt > DateTime.UtcNow
&& c.Attempts < 5)
.OrderByDescending(c => c.CreatedAt)
.FirstOrDefaultAsync();
if (challenge == null) return false;
challenge.Attempts++;
// Timing-safe comparison
bool isValid = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(challenge.Code),
Encoding.UTF8.GetBytes(code)
);
if (isValid) challenge.VerifiedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return isValid;
}
private static string MaskPhoneNumber(string phone) =>
System.Text.RegularExpressions.Regex.Replace(phone, @"\d(?=\d{4})", "*");
}SMS MFA Endpoint
app.post('/api/mfa/sms/send', async (req: Request, res: Response) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.phoneNumber) {
return res.status(400).json({ error: 'Phone number not configured' });
}
await sendSMSCode(userId, user.phoneNumber);
res.json({
success: true,
message: `Code sent to ${maskPhoneNumber(user.phoneNumber)}`
});
} catch (error) {
res.status(429).json({ error: 'Too many requests' });
}
});
app.post('/api/auth/verify-sms', async (req: Request, res: Response) => {
try {
const { userId, code } = req.body;
const valid = await verifySMSCode(userId, code);
if (!valid) {
return res.status(401).json({ error: 'Invalid or expired code' });
}
const session = await createSession(userId);
res.json({ sessionToken: session.token });
} catch (error) {
res.status(500).json({ error: 'SMS verification failed' });
}
});@RestController
@RequestMapping("/api")
public class SmsController {
private final SmsService smsService;
private final UserRepository userRepo;
private final SessionService sessionService;
public SmsController(SmsService smsService, UserRepository userRepo,
SessionService sessionService) {
this.smsService = smsService;
this.userRepo = userRepo;
this.sessionService = sessionService;
}
@PostMapping("/mfa/sms/send")
@Authorize
public ResponseEntity<?> sendSms(@AuthenticationPrincipal UserPrincipal principal) {
try {
User user = userRepo.findById(principal.getId())
.orElseThrow(() -> new RuntimeException("User not found"));
if (user.getPhoneNumber() == null) {
return ResponseEntity.status(400).body(Map.of("error", "Phone number not configured"));
}
smsService.sendSmsCode(principal.getId(), user.getPhoneNumber());
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Code sent to " + smsService.maskPhoneNumber(user.getPhoneNumber())
));
} catch (RuntimeException e) {
return ResponseEntity.status(429).body(Map.of("error", "Too many requests"));
}
}
@PostMapping("/auth/verify-sms")
public ResponseEntity<?> verifySms(@RequestBody Map<String, String> body) {
try {
boolean valid = smsService.verifySmsCode(body.get("userId"), body.get("code"));
if (!valid) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid or expired code"));
}
var session = sessionService.createSession(body.get("userId"));
return ResponseEntity.ok(Map.of("sessionToken", session.getToken()));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "SMS verification failed"));
}
}
}from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
router = APIRouter()
class VerifySmsBody(BaseModel):
user_id: str
code: str
@router.post("/mfa/sms/send")
async def send_sms_endpoint(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.id == current_user.id).first()
if not user or not user.phone_number:
raise HTTPException(status_code=400, detail="Phone number not configured")
try:
send_sms_code(current_user.id, user.phone_number, db)
return {
"success": True,
"message": f"Code sent to {mask_phone_number(user.phone_number)}",
}
except ValueError:
raise HTTPException(status_code=429, detail="Too many requests")
@router.post("/auth/verify-sms")
async def verify_sms_endpoint(body: VerifySmsBody, db: Session = Depends(get_db)):
valid = verify_sms_code(body.user_id, body.code, db)
if not valid:
raise HTTPException(status_code=401, detail="Invalid or expired code")
session = create_session(body.user_id)
return {"session_token": session.token}[ApiController]
[Route("api")]
public class SmsController : ControllerBase
{
private readonly SmsService _smsService;
private readonly AppDbContext _db;
private readonly SessionService _sessionService;
public SmsController(SmsService smsService, AppDbContext db, SessionService sessionService)
{
_smsService = smsService;
_db = db;
_sessionService = sessionService;
}
[HttpPost("mfa/sms/send")]
[Authorize]
public async Task<IActionResult> SendSms()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var user = await _db.Users.FindAsync(userId);
if (user?.PhoneNumber == null)
return BadRequest(new { error = "Phone number not configured" });
try
{
await _smsService.SendSmsCodeAsync(userId, user.PhoneNumber);
return Ok(new { success = true, message = $"Code sent to {SmsService.MaskPhoneNumber(user.PhoneNumber)}" });
}
catch (InvalidOperationException)
{
return StatusCode(429, new { error = "Too many requests" });
}
}
[HttpPost("auth/verify-sms")]
public async Task<IActionResult> VerifySms([FromBody] VerifySmsRequest request)
{
var valid = await _smsService.VerifySmsCodeAsync(request.UserId, request.Code);
if (!valid)
return Unauthorized(new { error = "Invalid or expired code" });
var session = await _sessionService.CreateSessionAsync(request.UserId);
return Ok(new { sessionToken = session.Token });
}
}Why SMS is weak: SIM swapping (attacker convinces carrier to switch your number), network interception, and delay attacks make SMS risky. Regulatory bodies like NIST now recommend against SMS-only MFA.
Hardware Security Keys (FIDO2/WebAuthn)
Hardware keys represent the strongest form of MFA. They use public-key cryptography and are immune to phishing.
Implementing WebAuthn Registration
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers/iso';
// Step 1: Begin WebAuthn registration
export async function beginWebAuthnRegistration(userId: string, userName: string) {
const options = generateRegistrationOptions({
rpID: process.env.WEBAUTHN_RP_ID || 'yourdomain.com',
rpName: 'Your Application',
userID: userId,
userName,
attestationType: 'direct',
supportedAlgorithmIDs: [-7, -257] // ES256, RS256
});
// Store challenge temporarily
await prisma.webAuthnChallenge.create({
data: {
userId,
challenge: isoBase64URL.toBuffer(options.challenge).toString('base64'),
expiresAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minute window
}
});
return options;
}
// Step 2: Verify WebAuthn registration
export async function completeWebAuthnRegistration(
userId: string,
credential: RegistrationResponseJSON
) {
const challenge = await prisma.webAuthnChallenge.findFirst({
where: {
userId,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' }
});
if (!challenge) {
throw new Error('Challenge expired or not found');
}
try {
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challenge.challenge,
expectedOrigin: process.env.WEBAUTHN_ORIGIN || 'https://yourdomain.com',
expectedRPID: process.env.WEBAUTHN_RP_ID || 'yourdomain.com'
});
if (!verification.verified) {
throw new Error('WebAuthn registration verification failed');
}
// Store credential
const credentialIdBuffer = isoBase64URL.toBuffer(credential.id);
await prisma.webAuthnCredential.create({
data: {
userId,
credentialId: credentialIdBuffer.toString('base64'),
publicKey: Buffer.from(verification.registrationInfo!.credentialPublicKey).toString('base64'),
counter: verification.registrationInfo!.counter,
transports: credential.response.transports || [],
aaguid: verification.registrationInfo!.aaguid || '',
deviceName: 'Security Key'
}
});
// Mark challenge as used
await prisma.webAuthnChallenge.update({
where: { id: challenge.id },
data: { usedAt: new Date() }
});
return { success: true };
} catch (error) {
throw error;
}
}// Spring Boot + webauthn4j
import com.webauthn4j.WebAuthnManager;
import com.webauthn4j.data.*;
import com.webauthn4j.data.client.Origin;
import com.webauthn4j.data.client.challenge.DefaultChallenge;
import org.springframework.stereotype.Service;
@Service
public class WebAuthnService {
private final WebAuthnChallengeRepository challengeRepo;
private final WebAuthnCredentialRepository credentialRepo;
private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
public WebAuthnService(WebAuthnChallengeRepository challengeRepo,
WebAuthnCredentialRepository credentialRepo) {
this.challengeRepo = challengeRepo;
this.credentialRepo = credentialRepo;
}
// Step 1: Begin WebAuthn registration
public PublicKeyCredentialCreationOptions beginRegistration(String userId, String userName) {
byte[] challengeBytes = new byte[32];
new SecureRandom().nextBytes(challengeBytes);
String challengeB64 = Base64.getEncoder().encodeToString(challengeBytes);
// Store challenge
WebAuthnChallenge challenge = new WebAuthnChallenge();
challenge.setUserId(userId);
challenge.setChallenge(challengeB64);
challenge.setExpiresAt(Instant.now().plusSeconds(10 * 60));
challengeRepo.save(challenge);
// Build creation options
PublicKeyCredentialRpEntity rp = new PublicKeyCredentialRpEntity(
System.getenv("WEBAUTHN_RP_ID"), "Your Application");
PublicKeyCredentialUserEntity user = new PublicKeyCredentialUserEntity(
userId.getBytes(StandardCharsets.UTF_8), userName, userName);
return new PublicKeyCredentialCreationOptions(
rp, user, new DefaultChallenge(challengeBytes),
List.of(
new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.ES256),
new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, COSEAlgorithmIdentifier.RS256)
)
);
}
// Step 2: Verify WebAuthn registration
@Transactional
public void completeRegistration(String userId, RegistrationRequest registrationRequest) {
WebAuthnChallenge storedChallenge = challengeRepo
.findFirstByUserIdAndExpiresAtAfterOrderByCreatedAtDesc(userId, Instant.now())
.orElseThrow(() -> new RuntimeException("Challenge expired or not found"));
byte[] challengeBytes = Base64.getDecoder().decode(storedChallenge.getChallenge());
Origin origin = new Origin(System.getenv("WEBAUTHN_ORIGIN"));
String rpId = System.getenv("WEBAUTHN_RP_ID");
RegistrationData data = webAuthnManager.parse(registrationRequest);
RegistrationParameters params = new RegistrationParameters(
new ServerProperty(origin, rpId, new DefaultChallenge(challengeBytes), null),
null, false, true
);
webAuthnManager.validate(data, params);
// Store credential
WebAuthnCredential credential = new WebAuthnCredential();
credential.setUserId(userId);
credential.setCredentialId(Base64.getEncoder().encodeToString(
data.getAttestationObject().getAuthenticatorData().getAttestedCredentialData().getCredentialId()));
credential.setPublicKey(Base64.getEncoder().encodeToString(
data.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()
.getCOSEKey().getBytes()));
credential.setCounter(data.getAttestationObject().getAuthenticatorData().getSignCount());
credential.setDeviceName("Security Key");
credentialRepo.save(credential);
storedChallenge.setUsedAt(Instant.now());
challengeRepo.save(storedChallenge);
}
}# FastAPI + py_webauthn
from webauthn import generate_registration_options, verify_registration_response
from webauthn.helpers.structs import (
RegistrationCredential, PublicKeyCredentialDescriptor,
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
import os, base64, secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from models import WebAuthnChallenge, WebAuthnCredential
RP_ID = os.environ.get("WEBAUTHN_RP_ID", "yourdomain.com")
RP_NAME = "Your Application"
ORIGIN = os.environ.get("WEBAUTHN_ORIGIN", "https://yourdomain.com")
def begin_webauthn_registration(user_id: str, user_name: str, db: Session):
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=user_id,
user_name=user_name,
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
],
)
challenge_b64 = base64.b64encode(options.challenge).decode()
db.add(WebAuthnChallenge(
user_id=user_id,
challenge=challenge_b64,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
))
db.commit()
return options
def complete_webauthn_registration(
user_id: str, credential: RegistrationCredential, db: Session
):
stored = (
db.query(WebAuthnChallenge)
.filter(
WebAuthnChallenge.user_id == user_id,
WebAuthnChallenge.expires_at > datetime.now(timezone.utc),
)
.order_by(WebAuthnChallenge.created_at.desc())
.first()
)
if not stored:
raise ValueError("Challenge expired or not found")
verification = verify_registration_response(
credential=credential,
expected_challenge=base64.b64decode(stored.challenge),
expected_rp_id=RP_ID,
expected_origin=ORIGIN,
)
if not verification.verified:
raise ValueError("WebAuthn registration verification failed")
db.add(WebAuthnCredential(
user_id=user_id,
credential_id=base64.b64encode(verification.credential_id).decode(),
public_key=base64.b64encode(verification.credential_public_key).decode(),
counter=verification.sign_count,
device_name="Security Key",
))
stored.used_at = datetime.now(timezone.utc)
db.commit()
return {"success": True}// ASP.NET Core + Fido2NetLib
using Fido2NetLib;
using Fido2NetLib.Objects;
public class WebAuthnService
{
private readonly AppDbContext _db;
private readonly IFido2 _fido2;
public WebAuthnService(AppDbContext db, IFido2 fido2)
{
_db = db;
_fido2 = fido2;
}
// Step 1: Begin WebAuthn registration
public async Task<CredentialCreateOptions> BeginRegistrationAsync(string userId, string userName)
{
var user = new Fido2User
{
Id = Encoding.UTF8.GetBytes(userId),
Name = userName,
DisplayName = userName
};
var options = _fido2.RequestNewCredential(
user,
new List<PublicKeyCredentialDescriptor>(),
AuthenticatorSelection.Default,
AttestationConveyancePreference.Direct
);
_db.WebAuthnChallenges.Add(new WebAuthnChallenge
{
UserId = userId,
Challenge = Convert.ToBase64String(options.Challenge),
ExpiresAt = DateTime.UtcNow.AddMinutes(10)
});
await _db.SaveChangesAsync();
return options;
}
// Step 2: Verify WebAuthn registration
public async Task CompleteRegistrationAsync(string userId, AuthenticatorAttestationRawResponse attestationResponse)
{
var stored = await _db.WebAuthnChallenges
.Where(c => c.UserId == userId && c.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(c => c.CreatedAt)
.FirstOrDefaultAsync()
?? throw new InvalidOperationException("Challenge expired or not found");
var options = CredentialCreateOptions.FromJson(
$"{{\"challenge\":\"{stored.Challenge}\"}}");
IsCredentialIdUniqueToUserAsyncDelegate callback = async (args, ct) =>
!await _db.WebAuthnCredentials.AnyAsync(c => c.CredentialId == Convert.ToBase64String(args.CredentialId));
var result = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback);
_db.WebAuthnCredentials.Add(new WebAuthnCredential
{
UserId = userId,
CredentialId = Convert.ToBase64String(result.Result!.CredentialId),
PublicKey = Convert.ToBase64String(result.Result.PublicKey),
Counter = result.Result.Counter,
DeviceName = "Security Key"
});
stored.UsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
}WebAuthn Authentication
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
// Step 1: Begin WebAuthn authentication
export async function beginWebAuthnAuthentication(userId: string) {
const credentials = await prisma.webAuthnCredential.findMany({
where: { userId }
});
if (credentials.length === 0) {
throw new Error('No security keys registered');
}
const options = generateAuthenticationOptions({
rpID: process.env.WEBAUTHN_RP_ID || 'yourdomain.com',
allowCredentials: credentials.map(cred => ({
id: cred.credentialId,
type: 'public-key',
transports: cred.transports
}))
});
// Store challenge
await prisma.webAuthnChallenge.create({
data: {
userId,
challenge: isoBase64URL.toBuffer(options.challenge).toString('base64'),
expiresAt: new Date(Date.now() + 10 * 60 * 1000)
}
});
return options;
}
// Step 2: Verify WebAuthn authentication
export async function completeWebAuthnAuthentication(
userId: string,
credential: AuthenticationResponseJSON
) {
const challenge = await prisma.webAuthnChallenge.findFirst({
where: {
userId,
usedAt: null,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' }
});
if (!challenge) {
throw new Error('Challenge expired');
}
const webAuthnCredential = await prisma.webAuthnCredential.findFirst({
where: {
userId,
credentialId: credential.id
}
});
if (!webAuthnCredential) {
throw new Error('Credential not found');
}
try {
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challenge.challenge,
expectedOrigin: process.env.WEBAUTHN_ORIGIN || 'https://yourdomain.com',
expectedRPID: process.env.WEBAUTHN_RP_ID || 'yourdomain.com',
credential: {
id: webAuthnCredential.credentialId,
publicKey: Buffer.from(webAuthnCredential.publicKey, 'base64'),
counter: webAuthnCredential.counter,
transports: webAuthnCredential.transports
}
});
if (!verification.verified) {
throw new Error('Authentication failed');
}
// Update counter to prevent cloned key attacks
await prisma.webAuthnCredential.update({
where: { id: webAuthnCredential.id },
data: { counter: verification.authenticationInfo.newCounter }
});
// Mark challenge as used
await prisma.webAuthnChallenge.update({
where: { id: challenge.id },
data: { usedAt: new Date() }
});
return { success: true };
} catch (error) {
throw error;
}
}// Continued in WebAuthnService
@Service
public class WebAuthnAuthService {
private final WebAuthnChallengeRepository challengeRepo;
private final WebAuthnCredentialRepository credentialRepo;
private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();
public WebAuthnAuthService(WebAuthnChallengeRepository challengeRepo,
WebAuthnCredentialRepository credentialRepo) {
this.challengeRepo = challengeRepo;
this.credentialRepo = credentialRepo;
}
// Step 1: Begin WebAuthn authentication
public PublicKeyCredentialRequestOptions beginAuthentication(String userId) {
List<WebAuthnCredential> credentials = credentialRepo.findByUserId(userId);
if (credentials.isEmpty()) throw new RuntimeException("No security keys registered");
byte[] challengeBytes = new byte[32];
new SecureRandom().nextBytes(challengeBytes);
String challengeB64 = Base64.getEncoder().encodeToString(challengeBytes);
WebAuthnChallenge challenge = new WebAuthnChallenge();
challenge.setUserId(userId);
challenge.setChallenge(challengeB64);
challenge.setExpiresAt(Instant.now().plusSeconds(10 * 60));
challengeRepo.save(challenge);
List<PublicKeyCredentialDescriptor> allowCredentials = credentials.stream()
.map(c -> new PublicKeyCredentialDescriptor(
PublicKeyCredentialType.PUBLIC_KEY,
Base64.getDecoder().decode(c.getCredentialId()),
null))
.toList();
return new PublicKeyCredentialRequestOptions(
new DefaultChallenge(challengeBytes),
60000L,
System.getenv("WEBAUTHN_RP_ID"),
allowCredentials,
UserVerificationRequirement.PREFERRED,
null
);
}
// Step 2: Verify WebAuthn authentication
@Transactional
public void completeAuthentication(String userId, AuthenticationRequest authRequest) {
WebAuthnChallenge stored = challengeRepo
.findFirstByUserIdAndUsedAtNullAndExpiresAtAfterOrderByCreatedAtDesc(userId, Instant.now())
.orElseThrow(() -> new RuntimeException("Challenge expired"));
String credentialIdB64 = Base64.getEncoder().encodeToString(authRequest.getCredentialId());
WebAuthnCredential webAuthnCredential = credentialRepo
.findByUserIdAndCredentialId(userId, credentialIdB64)
.orElseThrow(() -> new RuntimeException("Credential not found"));
byte[] challengeBytes = Base64.getDecoder().decode(stored.getChallenge());
Origin origin = new Origin(System.getenv("WEBAUTHN_ORIGIN"));
String rpId = System.getenv("WEBAUTHN_RP_ID");
AuthenticatorData authenticatorData = new AuthenticatorData(
authRequest.getAuthenticatorData());
ServerProperty serverProperty = new ServerProperty(
origin, rpId, new DefaultChallenge(challengeBytes), null);
AuthenticationParameters params = new AuthenticationParameters(
serverProperty,
new AttestedCredentialData(
null,
authRequest.getCredentialId(),
COSEKeyUtil.parse(Base64.getDecoder().decode(webAuthnCredential.getPublicKey()))
),
webAuthnCredential.getCounter(),
true, true
);
webAuthnManager.validate(new AuthenticationData(
authRequest.getCredentialId(),
authRequest.getUserHandle(),
authenticatorData,
authRequest.getClientDataJSON(),
authRequest.getSignature()
), params);
// Update counter to prevent cloned key attacks
webAuthnCredential.setCounter(authenticatorData.getSignCount());
credentialRepo.save(webAuthnCredential);
stored.setUsedAt(Instant.now());
challengeRepo.save(stored);
}
}def begin_webauthn_authentication(user_id: str, db: Session):
from webauthn import generate_authentication_options
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
credentials = db.query(WebAuthnCredential).filter(
WebAuthnCredential.user_id == user_id
).all()
if not credentials:
raise ValueError("No security keys registered")
allow_credentials = [
PublicKeyCredentialDescriptor(id=base64.b64decode(c.credential_id))
for c in credentials
]
options = generate_authentication_options(
rp_id=RP_ID,
allow_credentials=allow_credentials,
)
challenge_b64 = base64.b64encode(options.challenge).decode()
db.add(WebAuthnChallenge(
user_id=user_id,
challenge=challenge_b64,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
))
db.commit()
return options
def complete_webauthn_authentication(
user_id: str, credential, db: Session
):
from webauthn import verify_authentication_response
stored = (
db.query(WebAuthnChallenge)
.filter(
WebAuthnChallenge.user_id == user_id,
WebAuthnChallenge.used_at == None,
WebAuthnChallenge.expires_at > datetime.now(timezone.utc),
)
.order_by(WebAuthnChallenge.created_at.desc())
.first()
)
if not stored:
raise ValueError("Challenge expired")
credential_id_b64 = base64.b64encode(credential.raw_id).decode()
webauthn_cred = db.query(WebAuthnCredential).filter(
WebAuthnCredential.user_id == user_id,
WebAuthnCredential.credential_id == credential_id_b64,
).first()
if not webauthn_cred:
raise ValueError("Credential not found")
verification = verify_authentication_response(
credential=credential,
expected_challenge=base64.b64decode(stored.challenge),
expected_rp_id=RP_ID,
expected_origin=ORIGIN,
credential_public_key=base64.b64decode(webauthn_cred.public_key),
credential_current_sign_count=webauthn_cred.counter,
)
if not verification.verified:
raise ValueError("Authentication failed")
# Update counter to prevent cloned key attacks
webauthn_cred.counter = verification.new_sign_count
stored.used_at = datetime.now(timezone.utc)
db.commit()
return {"success": True}// Continued in WebAuthnService (Fido2NetLib)
public async Task<AssertionOptions> BeginAuthenticationAsync(string userId)
{
var credentials = await _db.WebAuthnCredentials
.Where(c => c.UserId == userId)
.ToListAsync();
if (!credentials.Any())
throw new InvalidOperationException("No security keys registered");
var allowedCredentials = credentials
.Select(c => new PublicKeyCredentialDescriptor(Convert.FromBase64String(c.CredentialId)))
.ToList();
var options = _fido2.GetAssertionOptions(allowedCredentials, UserVerificationRequirement.Preferred);
_db.WebAuthnChallenges.Add(new WebAuthnChallenge
{
UserId = userId,
Challenge = Convert.ToBase64String(options.Challenge),
ExpiresAt = DateTime.UtcNow.AddMinutes(10)
});
await _db.SaveChangesAsync();
return options;
}
public async Task CompleteAuthenticationAsync(string userId, AuthenticatorAssertionRawResponse assertionResponse)
{
var stored = await _db.WebAuthnChallenges
.Where(c => c.UserId == userId && c.UsedAt == null && c.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(c => c.CreatedAt)
.FirstOrDefaultAsync()
?? throw new InvalidOperationException("Challenge expired");
var credentialIdB64 = Convert.ToBase64String(assertionResponse.Id);
var webAuthnCred = await _db.WebAuthnCredentials
.Where(c => c.UserId == userId && c.CredentialId == credentialIdB64)
.FirstOrDefaultAsync()
?? throw new InvalidOperationException("Credential not found");
var options = AssertionOptions.FromJson($"{{\"challenge\":\"{stored.Challenge}\"}}");
IsUserHandleOwnerOfCredentialIdAsync callback = async (args, ct) =>
await _db.WebAuthnCredentials.AnyAsync(
c => c.CredentialId == Convert.ToBase64String(args.CredentialId)
&& c.UserId == userId);
var result = await _fido2.MakeAssertionAsync(
assertionResponse, options,
Convert.FromBase64String(webAuthnCred.PublicKey),
webAuthnCred.Counter, callback);
// Update counter to prevent cloned key attacks
webAuthnCred.Counter = result.Counter;
stored.UsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}Express.js endpoints for WebAuthn
app.post('/api/mfa/webauthn/register/begin', async (req: Request, res: Response) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const options = await beginWebAuthnRegistration(userId, req.user.email);
res.json(options);
} catch (error) {
res.status(500).json({ error: 'Failed to initiate registration' });
}
});
app.post('/api/mfa/webauthn/register/complete', async (req: Request, res: Response) => {
try {
const userId = req.user?.id;
const { credential } = req.body;
if (!userId || !credential) {
return res.status(400).json({ error: 'Missing required fields' });
}
await completeWebAuthnRegistration(userId, credential);
res.json({ success: true, message: 'Security key registered successfully' });
} catch (error) {
res.status(400).json({ error: 'Registration failed' });
}
});
app.post('/api/auth/webauthn/begin', async (req: Request, res: Response) => {
try {
const { userId } = req.body;
const options = await beginWebAuthnAuthentication(userId);
res.json(options);
} catch (error) {
res.status(400).json({ error: 'Authentication initiation failed' });
}
});
app.post('/api/auth/webauthn/complete', async (req: Request, res: Response) => {
try {
const { userId, credential } = req.body;
await completeWebAuthnAuthentication(userId, credential);
const session = await createSession(userId);
res.json({ sessionToken: session.token });
} catch (error) {
res.status(401).json({ error: 'Authentication failed' });
}
});@RestController
@RequestMapping("/api")
public class WebAuthnController {
private final WebAuthnService webAuthnService;
private final WebAuthnAuthService webAuthnAuthService;
private final SessionService sessionService;
public WebAuthnController(WebAuthnService webAuthnService,
WebAuthnAuthService webAuthnAuthService,
SessionService sessionService) {
this.webAuthnService = webAuthnService;
this.webAuthnAuthService = webAuthnAuthService;
this.sessionService = sessionService;
}
@PostMapping("/mfa/webauthn/register/begin")
@Authorize
public ResponseEntity<?> beginRegistration(@AuthenticationPrincipal UserPrincipal principal) {
try {
var options = webAuthnService.beginRegistration(principal.getId(), principal.getEmail());
return ResponseEntity.ok(options);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "Failed to initiate registration"));
}
}
@PostMapping("/mfa/webauthn/register/complete")
@Authorize
public ResponseEntity<?> completeRegistration(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody RegistrationRequest registrationRequest) {
try {
webAuthnService.completeRegistration(principal.getId(), registrationRequest);
return ResponseEntity.ok(Map.of("success", true, "message", "Security key registered successfully"));
} catch (Exception e) {
return ResponseEntity.status(400).body(Map.of("error", "Registration failed"));
}
}
@PostMapping("/auth/webauthn/begin")
public ResponseEntity<?> beginAuthentication(@RequestBody Map<String, String> body) {
try {
var options = webAuthnAuthService.beginAuthentication(body.get("userId"));
return ResponseEntity.ok(options);
} catch (Exception e) {
return ResponseEntity.status(400).body(Map.of("error", "Authentication initiation failed"));
}
}
@PostMapping("/auth/webauthn/complete")
public ResponseEntity<?> completeAuthentication(@RequestBody Map<String, Object> body) {
try {
String userId = (String) body.get("userId");
webAuthnAuthService.completeAuthentication(userId, (AuthenticationRequest) body.get("credential"));
var session = sessionService.createSession(userId);
return ResponseEntity.ok(Map.of("sessionToken", session.getToken()));
} catch (Exception e) {
return ResponseEntity.status(401).body(Map.of("error", "Authentication failed"));
}
}
}from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.post("/mfa/webauthn/register/begin")
async def webauthn_register_begin(
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
options = begin_webauthn_registration(current_user.id, current_user.email, db)
return options
except Exception:
raise HTTPException(status_code=500, detail="Failed to initiate registration")
@router.post("/mfa/webauthn/register/complete")
async def webauthn_register_complete(
credential,
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
complete_webauthn_registration(current_user.id, credential, db)
return {"success": True, "message": "Security key registered successfully"}
except Exception:
raise HTTPException(status_code=400, detail="Registration failed")
@router.post("/auth/webauthn/begin")
async def webauthn_auth_begin(body: dict, db: Session = Depends(get_db)):
try:
options = begin_webauthn_authentication(body["user_id"], db)
return options
except Exception:
raise HTTPException(status_code=400, detail="Authentication initiation failed")
@router.post("/auth/webauthn/complete")
async def webauthn_auth_complete(body: dict, db: Session = Depends(get_db)):
try:
complete_webauthn_authentication(body["user_id"], body["credential"], db)
session = create_session(body["user_id"])
return {"session_token": session.token}
except Exception:
raise HTTPException(status_code=401, detail="Authentication failed")[ApiController]
[Route("api")]
public class WebAuthnController : ControllerBase
{
private readonly WebAuthnService _webAuthnService;
private readonly SessionService _sessionService;
public WebAuthnController(WebAuthnService webAuthnService, SessionService sessionService)
{
_webAuthnService = webAuthnService;
_sessionService = sessionService;
}
[HttpPost("mfa/webauthn/register/begin")]
[Authorize]
public async Task<IActionResult> BeginRegistration()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
var email = User.FindFirstValue(ClaimTypes.Email)!;
var options = await _webAuthnService.BeginRegistrationAsync(userId, email);
return Ok(options);
}
[HttpPost("mfa/webauthn/register/complete")]
[Authorize]
public async Task<IActionResult> CompleteRegistration(
[FromBody] AuthenticatorAttestationRawResponse attestationResponse)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
try
{
await _webAuthnService.CompleteRegistrationAsync(userId, attestationResponse);
return Ok(new { success = true, message = "Security key registered successfully" });
}
catch
{
return BadRequest(new { error = "Registration failed" });
}
}
[HttpPost("auth/webauthn/begin")]
public async Task<IActionResult> BeginAuthentication([FromBody] BeginAuthRequest request)
{
try
{
var options = await _webAuthnService.BeginAuthenticationAsync(request.UserId);
return Ok(options);
}
catch
{
return BadRequest(new { error = "Authentication initiation failed" });
}
}
[HttpPost("auth/webauthn/complete")]
public async Task<IActionResult> CompleteAuthentication(
[FromBody] WebAuthnCompleteRequest request)
{
try
{
await _webAuthnService.CompleteAuthenticationAsync(request.UserId, request.Credential);
var session = await _sessionService.CreateSessionAsync(request.UserId);
return Ok(new { sessionToken = session.Token });
}
catch
{
return Unauthorized(new { error = "Authentication failed" });
}
}
}Adaptive/Risk-Based MFA
Not every login requires the same level of verification. Adaptive MFA increases friction only when necessary.
Risk Scoring System
interface RiskFactors {
newDeviceId: boolean;
unusualLocation: boolean;
unusualTime: boolean;
travelImpossible: boolean;
ipReputation: number;
failedAttempts: number;
}
async function calculateRiskScore(userId: string, context: {
ipAddress: string;
userAgent: string;
timestamp: Date;
}): Promise<{ score: number; factors: RiskFactors }> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('User not found');
let score = 0;
const factors: RiskFactors = {
newDeviceId: false,
unusualLocation: false,
unusualTime: false,
travelImpossible: false,
ipReputation: 0,
failedAttempts: 0
};
// 1. Check device recognition
const existingDevice = await prisma.device.findFirst({
where: {
userId,
userAgent: context.userAgent
}
});
if (!existingDevice) {
score += 25;
factors.newDeviceId = true;
} else {
// Update last seen
await prisma.device.update({
where: { id: existingDevice.id },
data: { lastSeenAt: context.timestamp }
});
}
// 2. Check location anomaly (using IP geolocation)
const geoLocation = await getIPGeolocation(context.ipAddress);
const lastLogin = await prisma.loginEvent.findFirst({
where: { userId },
orderBy: { timestamp: 'desc' },
take: 1
});
if (lastLogin) {
const distance = calculateDistance(
{ lat: lastLogin.latitude, lon: lastLogin.longitude },
{ lat: geoLocation.latitude, lon: geoLocation.longitude }
);
const timeDiff = (context.timestamp.getTime() - lastLogin.timestamp.getTime()) / 1000 / 3600; // hours
// Impossible travel: distance > 900km/hour is suspicious
const maxPossibleDistance = timeDiff * 900; // km/h
if (distance > maxPossibleDistance) {
score += 40;
factors.travelImpossible = true;
} else if (distance > 100 && timeDiff < 6) {
score += 20;
factors.unusualLocation = true;
}
}
// 3. Check time-based anomaly
const hour = context.timestamp.getHours();
const lastLogins = await prisma.loginEvent.findMany({
where: { userId },
orderBy: { timestamp: 'desc' },
take: 10
});
const typicalHours = lastLogins
.map(l => new Date(l.timestamp).getHours())
.filter(h => h >= 6 && h <= 23);
if (typicalHours.length > 5 && !typicalHours.includes(hour)) {
score += 15;
factors.unusualTime = true;
}
// 4. Check IP reputation (integration with threat intelligence)
const ipRep = await checkIPReputation(context.ipAddress);
if (ipRep.riskScore > 50) {
score += Math.min(30, ipRep.riskScore / 2);
factors.ipReputation = ipRep.riskScore;
}
// 5. Check recent failed attempts
const recentFailures = await prisma.failedLogin.count({
where: {
userId,
timestamp: {
gt: new Date(Date.now() - 15 * 60 * 1000) // Last 15 minutes
}
}
});
if (recentFailures > 0) {
score += Math.min(30, recentFailures * 10);
factors.failedAttempts = recentFailures;
}
return { score: Math.min(100, score), factors };
}
async function shouldRequireMFA(userId: string, riskScore: number): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { mfaSettings: true }
});
if (!user?.mfaSettings) {
return false;
}
// User's MFA threshold (e.g., "require MFA when risk > 30")
return riskScore > (user.mfaSettings.riskThreshold || 30);
}@Service
public class RiskScoringService {
private final UserRepository userRepo;
private final DeviceRepository deviceRepo;
private final LoginEventRepository loginEventRepo;
private final FailedLoginRepository failedLoginRepo;
private final IpGeolocationService geoService;
private final IpReputationService ipRepService;
public RiskScoringService(UserRepository userRepo, DeviceRepository deviceRepo,
LoginEventRepository loginEventRepo,
FailedLoginRepository failedLoginRepo,
IpGeolocationService geoService,
IpReputationService ipRepService) {
this.userRepo = userRepo;
this.deviceRepo = deviceRepo;
this.loginEventRepo = loginEventRepo;
this.failedLoginRepo = failedLoginRepo;
this.geoService = geoService;
this.ipRepService = ipRepService;
}
@Transactional
public RiskResult calculateRiskScore(String userId, String ipAddress, String userAgent, Instant timestamp) {
User user = userRepo.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
int score = 0;
RiskFactors factors = new RiskFactors();
// 1. Check device recognition
Optional<Device> existingDevice = deviceRepo.findByUserIdAndUserAgent(userId, userAgent);
if (existingDevice.isEmpty()) {
score += 25;
factors.setNewDeviceId(true);
} else {
existingDevice.get().setLastSeenAt(timestamp);
deviceRepo.save(existingDevice.get());
}
// 2. Check location anomaly
GeoLocation geoLocation = geoService.lookup(ipAddress);
Optional<LoginEvent> lastLogin = loginEventRepo
.findFirstByUserIdOrderByTimestampDesc(userId);
if (lastLogin.isPresent()) {
double distance = calculateDistance(
lastLogin.get().getLatitude(), lastLogin.get().getLongitude(),
geoLocation.getLatitude(), geoLocation.getLongitude());
double timeDiffHours = Duration.between(lastLogin.get().getTimestamp(), timestamp)
.toMinutes() / 60.0;
double maxPossibleDistance = timeDiffHours * 900;
if (distance > maxPossibleDistance) {
score += 40;
factors.setTravelImpossible(true);
} else if (distance > 100 && timeDiffHours < 6) {
score += 20;
factors.setUnusualLocation(true);
}
}
// 3. Check time-based anomaly
int hour = LocalTime.ofInstant(timestamp, ZoneOffset.UTC).getHour();
List<Integer> typicalHours = loginEventRepo.findTop10ByUserIdOrderByTimestampDesc(userId)
.stream()
.map(e -> LocalTime.ofInstant(e.getTimestamp(), ZoneOffset.UTC).getHour())
.filter(h -> h >= 6 && h <= 23)
.toList();
if (typicalHours.size() > 5 && !typicalHours.contains(hour)) {
score += 15;
factors.setUnusualTime(true);
}
// 4. Check IP reputation
IpReputation ipRep = ipRepService.check(ipAddress);
if (ipRep.getRiskScore() > 50) {
score += Math.min(30, ipRep.getRiskScore() / 2);
factors.setIpReputation(ipRep.getRiskScore());
}
// 5. Check recent failed attempts
long recentFailures = failedLoginRepo.countByUserIdAndTimestampAfter(
userId, Instant.now().minusSeconds(15 * 60));
if (recentFailures > 0) {
score += Math.min(30, (int)(recentFailures * 10));
factors.setFailedAttempts((int) recentFailures);
}
return new RiskResult(Math.min(100, score), factors);
}
public boolean shouldRequireMFA(String userId, int riskScore) {
return userRepo.findById(userId)
.map(u -> u.getMfaSettings() != null
&& riskScore > (u.getMfaSettings().getRiskThreshold() != null
? u.getMfaSettings().getRiskThreshold() : 30))
.orElse(false);
}
}from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from models import User, Device, LoginEvent, FailedLogin
@dataclass
class RiskFactors:
new_device_id: bool = False
unusual_location: bool = False
unusual_time: bool = False
travel_impossible: bool = False
ip_reputation: int = 0
failed_attempts: int = 0
@dataclass
class RiskResult:
score: int
factors: RiskFactors
def calculate_risk_score(
user_id: str, ip_address: str, user_agent: str, timestamp: datetime, db: Session
) -> RiskResult:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
score = 0
factors = RiskFactors()
# 1. Check device recognition
existing_device = db.query(Device).filter(
Device.user_id == user_id, Device.user_agent == user_agent
).first()
if not existing_device:
score += 25
factors.new_device_id = True
else:
existing_device.last_seen_at = timestamp
db.commit()
# 2. Check location anomaly
geo = get_ip_geolocation(ip_address)
last_login = (
db.query(LoginEvent)
.filter(LoginEvent.user_id == user_id)
.order_by(LoginEvent.timestamp.desc())
.first()
)
if last_login:
distance = calculate_distance(
(last_login.latitude, last_login.longitude),
(geo["latitude"], geo["longitude"]),
)
time_diff_hours = (timestamp - last_login.timestamp).total_seconds() / 3600
max_possible = time_diff_hours * 900
if distance > max_possible:
score += 40
factors.travel_impossible = True
elif distance > 100 and time_diff_hours < 6:
score += 20
factors.unusual_location = True
# 3. Check time-based anomaly
hour = timestamp.hour
last_logins = (
db.query(LoginEvent)
.filter(LoginEvent.user_id == user_id)
.order_by(LoginEvent.timestamp.desc())
.limit(10)
.all()
)
typical_hours = [l.timestamp.hour for l in last_logins if 6 <= l.timestamp.hour <= 23]
if len(typical_hours) > 5 and hour not in typical_hours:
score += 15
factors.unusual_time = True
# 4. Check IP reputation
ip_rep = check_ip_reputation(ip_address)
if ip_rep["risk_score"] > 50:
score += min(30, ip_rep["risk_score"] // 2)
factors.ip_reputation = ip_rep["risk_score"]
# 5. Check recent failed attempts
cutoff = datetime.now(timezone.utc) - timedelta(minutes=15)
recent_failures = db.query(FailedLogin).filter(
FailedLogin.user_id == user_id, FailedLogin.timestamp > cutoff
).count()
if recent_failures > 0:
score += min(30, recent_failures * 10)
factors.failed_attempts = recent_failures
return RiskResult(score=min(100, score), factors=factors)
def should_require_mfa(user_id: str, risk_score: int, db: Session) -> bool:
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.mfa_settings:
return False
threshold = user.mfa_settings.risk_threshold or 30
return risk_score > thresholdpublic class RiskScoringService
{
private readonly AppDbContext _db;
private readonly IIpGeolocationService _geoService;
private readonly IIpReputationService _ipRepService;
public RiskScoringService(AppDbContext db, IIpGeolocationService geoService,
IIpReputationService ipRepService)
{
_db = db;
_geoService = geoService;
_ipRepService = ipRepService;
}
public async Task<RiskResult> CalculateRiskScoreAsync(
string userId, string ipAddress, string userAgent, DateTime timestamp)
{
var user = await _db.Users.FindAsync(userId)
?? throw new InvalidOperationException("User not found");
int score = 0;
var factors = new RiskFactors();
// 1. Check device recognition
var existingDevice = await _db.Devices
.FirstOrDefaultAsync(d => d.UserId == userId && d.UserAgent == userAgent);
if (existingDevice == null)
{
score += 25;
factors.NewDeviceId = true;
}
else
{
existingDevice.LastSeenAt = timestamp;
await _db.SaveChangesAsync();
}
// 2. Check location anomaly
var geo = await _geoService.LookupAsync(ipAddress);
var lastLogin = await _db.LoginEvents
.Where(e => e.UserId == userId)
.OrderByDescending(e => e.Timestamp)
.FirstOrDefaultAsync();
if (lastLogin != null)
{
double distance = CalculateDistance(
lastLogin.Latitude, lastLogin.Longitude,
geo.Latitude, geo.Longitude);
double timeDiffHours = (timestamp - lastLogin.Timestamp).TotalHours;
double maxPossible = timeDiffHours * 900;
if (distance > maxPossible)
{
score += 40;
factors.TravelImpossible = true;
}
else if (distance > 100 && timeDiffHours < 6)
{
score += 20;
factors.UnusualLocation = true;
}
}
// 3. Check time-based anomaly
int hour = timestamp.Hour;
var recentHours = await _db.LoginEvents
.Where(e => e.UserId == userId)
.OrderByDescending(e => e.Timestamp)
.Take(10)
.Select(e => e.Timestamp.Hour)
.Where(h => h >= 6 && h <= 23)
.ToListAsync();
if (recentHours.Count > 5 && !recentHours.Contains(hour))
{
score += 15;
factors.UnusualTime = true;
}
// 4. Check IP reputation
var ipRep = await _ipRepService.CheckAsync(ipAddress);
if (ipRep.RiskScore > 50)
{
score += Math.Min(30, ipRep.RiskScore / 2);
factors.IpReputation = ipRep.RiskScore;
}
// 5. Check recent failed attempts
var cutoff = DateTime.UtcNow.AddMinutes(-15);
int recentFailures = await _db.FailedLogins
.CountAsync(f => f.UserId == userId && f.Timestamp > cutoff);
if (recentFailures > 0)
{
score += Math.Min(30, recentFailures * 10);
factors.FailedAttempts = recentFailures;
}
return new RiskResult(Math.Min(100, score), factors);
}
public async Task<bool> ShouldRequireMfaAsync(string userId, int riskScore)
{
var user = await _db.Users
.Include(u => u.MfaSettings)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user?.MfaSettings == null) return false;
return riskScore > (user.MfaSettings.RiskThreshold ?? 30);
}
}Adaptive MFA in Login Flow
app.post('/api/auth/login', async (req: Request, res: Response) => {
try {
const { email, password, deviceFingerprint } = req.body;
const ipAddress = req.ip || '';
const userAgent = req.get('user-agent') || '';
const timestamp = new Date();
// Verify credentials
const user = await authenticateUser(email, password);
if (!user) {
await prisma.failedLogin.create({
data: { userId: '', ipAddress, timestamp }
});
return res.status(401).json({ error: 'Invalid credentials' });
}
// Calculate risk
const { score: riskScore, factors } = await calculateRiskScore(user.id, {
ipAddress,
userAgent,
timestamp
});
// Log login event
const geoLocation = await getIPGeolocation(ipAddress);
await prisma.loginEvent.create({
data: {
userId: user.id,
ipAddress,
latitude: geoLocation.latitude,
longitude: geoLocation.longitude,
riskScore,
timestamp,
factors: JSON.stringify(factors)
}
});
// Determine if MFA is required
const requireMFA = await shouldRequireMFA(user.id, riskScore);
if (requireMFA) {
// Generate temporary auth token for MFA challenge
const tempToken = jwt.sign(
{ userId: user.id, type: 'mfa_challenge' },
process.env.JWT_SECRET!,
{ expiresIn: '10m' }
);
return res.json({
requiresMFA: true,
mfaToken: tempToken,
availableMethods: user.mfaMethods, // ['totp', 'webauthn', 'sms']
riskFactors: factors
});
}
// No MFA required, issue session
const session = await createSession(user.id);
res.json({ sessionToken: session.token });
} catch (error) {
res.status(500).json({ error: 'Login failed' });
}
});@RestController
@RequestMapping("/api/auth")
public class AdaptiveLoginController {
private final AuthService authService;
private final RiskScoringService riskScoringService;
private final IpGeolocationService geoService;
private final LoginEventRepository loginEventRepo;
private final FailedLoginRepository failedLoginRepo;
private final SessionService sessionService;
private final JwtService jwtService;
// constructor omitted for brevity
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest body,
HttpServletRequest request) {
try {
String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader("User-Agent");
Instant timestamp = Instant.now();
User user = authService.authenticateUser(body.getEmail(), body.getPassword());
if (user == null) {
failedLoginRepo.save(new FailedLogin("", ipAddress, timestamp));
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
}
RiskResult risk = riskScoringService.calculateRiskScore(
user.getId(), ipAddress, userAgent, timestamp);
GeoLocation geo = geoService.lookup(ipAddress);
loginEventRepo.save(new LoginEvent(user.getId(), ipAddress,
geo.getLatitude(), geo.getLongitude(), risk.getScore(), timestamp));
boolean requireMfa = riskScoringService.shouldRequireMFA(user.getId(), risk.getScore());
if (requireMfa) {
String tempToken = jwtService.sign(
Map.of("userId", user.getId(), "type", "mfa_challenge"),
Duration.ofMinutes(10));
return ResponseEntity.ok(Map.of(
"requiresMFA", true,
"mfaToken", tempToken,
"availableMethods", user.getMfaMethods(),
"riskFactors", risk.getFactors()
));
}
var session = sessionService.createSession(user.getId());
return ResponseEntity.ok(Map.of("sessionToken", session.getToken()));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "Login failed"));
}
}
}from fastapi import APIRouter, Request, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from database import get_db
router = APIRouter()
class LoginBody(BaseModel):
email: str
password: str
@router.post("/auth/login")
async def adaptive_login(body: LoginBody, request: Request, db: Session = Depends(get_db)):
try:
ip_address = request.client.host or ""
user_agent = request.headers.get("user-agent", "")
timestamp = datetime.now(timezone.utc)
user = authenticate_user(body.email, body.password, db)
if not user:
db.add(FailedLogin(user_id="", ip_address=ip_address, timestamp=timestamp))
db.commit()
raise HTTPException(status_code=401, detail="Invalid credentials")
risk = calculate_risk_score(user.id, ip_address, user_agent, timestamp, db)
geo = get_ip_geolocation(ip_address)
db.add(LoginEvent(
user_id=user.id,
ip_address=ip_address,
latitude=geo["latitude"],
longitude=geo["longitude"],
risk_score=risk.score,
timestamp=timestamp,
factors=str(risk.factors),
))
db.commit()
require_mfa = should_require_mfa(user.id, risk.score, db)
if require_mfa:
temp_token = create_jwt(
{"user_id": user.id, "type": "mfa_challenge"}, expires_minutes=10
)
return {
"requires_mfa": True,
"mfa_token": temp_token,
"available_methods": user.mfa_methods,
"risk_factors": risk.factors,
}
session = create_session(user.id)
return {"session_token": session.token}
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="Login failed")[ApiController]
[Route("api/auth")]
public class AdaptiveLoginController : ControllerBase
{
private readonly IAuthService _authService;
private readonly RiskScoringService _riskService;
private readonly IIpGeolocationService _geoService;
private readonly AppDbContext _db;
private readonly SessionService _sessionService;
private readonly IJwtService _jwtService;
// constructor omitted for brevity
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest body)
{
try
{
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "";
var userAgent = Request.Headers.UserAgent.ToString();
var timestamp = DateTime.UtcNow;
var user = await _authService.AuthenticateAsync(body.Email, body.Password);
if (user == null)
{
_db.FailedLogins.Add(new FailedLogin { UserId = "", IpAddress = ipAddress, Timestamp = timestamp });
await _db.SaveChangesAsync();
return Unauthorized(new { error = "Invalid credentials" });
}
var risk = await _riskService.CalculateRiskScoreAsync(
user.Id, ipAddress, userAgent, timestamp);
var geo = await _geoService.LookupAsync(ipAddress);
_db.LoginEvents.Add(new LoginEvent
{
UserId = user.Id, IpAddress = ipAddress,
Latitude = geo.Latitude, Longitude = geo.Longitude,
RiskScore = risk.Score, Timestamp = timestamp
});
await _db.SaveChangesAsync();
bool requireMfa = await _riskService.ShouldRequireMfaAsync(user.Id, risk.Score);
if (requireMfa)
{
var tempToken = _jwtService.Sign(
new { userId = user.Id, type = "mfa_challenge" },
TimeSpan.FromMinutes(10));
return Ok(new
{
requiresMFA = true,
mfaToken = tempToken,
availableMethods = user.MfaMethods,
riskFactors = risk.Factors
});
}
var session = await _sessionService.CreateSessionAsync(user.Id);
return Ok(new { sessionToken = session.Token });
}
catch
{
return StatusCode(500, new { error = "Login failed" });
}
}
}Account Recovery When MFA Device is Lost
Losing access to your MFA device is a nightmare scenario. Have a recovery process ready.
Recovery Code System
export async function initiateAccountRecovery(email: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
// Don't reveal if user exists
return { success: true };
}
const recoveryCode = crypto.randomBytes(32).toString('hex');
const codeHash = crypto.createHash('sha256').update(recoveryCode).digest('hex');
await prisma.recoveryToken.create({
data: {
userId: user.id,
tokenHash: codeHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
usedAt: null
}
});
// Send email with recovery link
await sendRecoveryEmail(user.email, recoveryCode);
return { success: true };
}
export async function verifyRecoveryCode(recoveryCode: string): Promise<string | null> {
const codeHash = crypto.createHash('sha256').update(recoveryCode).digest('hex');
const token = await prisma.recoveryToken.findFirst({
where: {
tokenHash: codeHash,
expiresAt: { gt: new Date() },
usedAt: null
}
});
if (!token) {
return null;
}
// Mark as used
await prisma.recoveryToken.update({
where: { id: token.id },
data: { usedAt: new Date() }
});
return token.userId;
}
export async function resetMFAForUser(userId: string) {
// Disable all MFA methods
await prisma.user.update({
where: { id: userId },
data: {
totpEnabled: false,
totpSecret: null
}
});
await prisma.webAuthnCredential.deleteMany({ where: { userId } });
await prisma.backupCode.deleteMany({ where: { userId } });
// Create new backup codes
const newCodes = await generateBackupCodes(userId);
// Log recovery event
await prisma.auditLog.create({
data: {
userId,
action: 'MFA_RESET_RECOVERY',
severity: 'HIGH',
timestamp: new Date()
}
});
return newCodes;
}@Service
public class AccountRecoveryService {
private final UserRepository userRepo;
private final RecoveryTokenRepository recoveryTokenRepo;
private final WebAuthnCredentialRepository webAuthnRepo;
private final BackupCodeRepository backupCodeRepo;
private final AuditLogRepository auditLogRepo;
private final BackupCodeService backupCodeService;
private final EmailService emailService;
// constructor omitted for brevity
@Transactional
public void initiateAccountRecovery(String email) {
User user = userRepo.findByEmail(email).orElse(null);
if (user == null) return; // Don't reveal if user exists
byte[] recoveryBytes = new byte[32];
new SecureRandom().nextBytes(recoveryBytes);
String recoveryCode = HexFormat.of().formatHex(recoveryBytes);
String codeHash = sha256Hex(recoveryCode);
RecoveryToken token = new RecoveryToken();
token.setUserId(user.getId());
token.setTokenHash(codeHash);
token.setExpiresAt(Instant.now().plusSeconds(24 * 3600));
recoveryTokenRepo.save(token);
emailService.sendRecoveryEmail(user.getEmail(), recoveryCode);
}
@Transactional
public Optional<String> verifyRecoveryCode(String recoveryCode) {
String codeHash = sha256Hex(recoveryCode);
RecoveryToken token = recoveryTokenRepo
.findFirstByTokenHashAndExpiresAtAfterAndUsedAtNull(codeHash, Instant.now())
.orElse(null);
if (token == null) return Optional.empty();
token.setUsedAt(Instant.now());
recoveryTokenRepo.save(token);
return Optional.of(token.getUserId());
}
@Transactional
public List<String> resetMFAForUser(String userId) {
// Disable all MFA methods
User user = userRepo.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
user.setTotpEnabled(false);
user.setTotpSecret(null);
userRepo.save(user);
webAuthnRepo.deleteByUserId(userId);
backupCodeRepo.deleteByUserId(userId);
List<String> newCodes = backupCodeService.generateBackupCodes(userId, 10);
AuditLog log = new AuditLog();
log.setUserId(userId);
log.setAction("MFA_RESET_RECOVERY");
log.setSeverity("HIGH");
log.setTimestamp(Instant.now());
auditLogRepo.save(log);
return newCodes;
}
private String sha256Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
}
}import secrets, hashlib
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from models import User, RecoveryToken, WebAuthnCredential, BackupCode, AuditLog
def initiate_account_recovery(email: str, db: Session) -> dict:
user = db.query(User).filter(User.email == email).first()
if not user:
return {"success": True} # Don't reveal if user exists
recovery_code = secrets.token_hex(32)
code_hash = hashlib.sha256(recovery_code.encode()).hexdigest()
db.add(RecoveryToken(
user_id=user.id,
token_hash=code_hash,
expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
used_at=None,
))
db.commit()
send_recovery_email(user.email, recovery_code)
return {"success": True}
def verify_recovery_code(recovery_code: str, db: Session) -> str | None:
code_hash = hashlib.sha256(recovery_code.encode()).hexdigest()
token = (
db.query(RecoveryToken)
.filter(
RecoveryToken.token_hash == code_hash,
RecoveryToken.expires_at > datetime.now(timezone.utc),
RecoveryToken.used_at == None,
)
.first()
)
if not token:
return None
token.used_at = datetime.now(timezone.utc)
db.commit()
return token.user_id
def reset_mfa_for_user(user_id: str, db: Session) -> list[str]:
# Disable all MFA methods
user = db.query(User).filter(User.id == user_id).first()
user.totp_enabled = False
user.totp_secret = None
db.query(WebAuthnCredential).filter(WebAuthnCredential.user_id == user_id).delete()
db.query(BackupCode).filter(BackupCode.user_id == user_id).delete()
new_codes = generate_backup_codes(user_id, db)
db.add(AuditLog(
user_id=user_id,
action="MFA_RESET_RECOVERY",
severity="HIGH",
timestamp=datetime.now(timezone.utc),
))
db.commit()
return new_codespublic class AccountRecoveryService
{
private readonly AppDbContext _db;
private readonly BackupCodeService _backupCodeService;
private readonly IEmailService _emailService;
public AccountRecoveryService(AppDbContext db, BackupCodeService backupCodeService,
IEmailService emailService)
{
_db = db;
_backupCodeService = backupCodeService;
_emailService = emailService;
}
public async Task InitiateAccountRecoveryAsync(string email)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user == null) return; // Don't reveal if user exists
var recoveryBytes = RandomNumberGenerator.GetBytes(32);
var recoveryCode = Convert.ToHexString(recoveryBytes).ToLower();
var codeHash = Sha256Hex(recoveryCode);
_db.RecoveryTokens.Add(new RecoveryToken
{
UserId = user.Id,
TokenHash = codeHash,
ExpiresAt = DateTime.UtcNow.AddHours(24),
UsedAt = null
});
await _db.SaveChangesAsync();
await _emailService.SendRecoveryEmailAsync(user.Email, recoveryCode);
}
public async Task<string?> VerifyRecoveryCodeAsync(string recoveryCode)
{
var codeHash = Sha256Hex(recoveryCode);
var token = await _db.RecoveryTokens
.Where(t => t.TokenHash == codeHash
&& t.ExpiresAt > DateTime.UtcNow
&& t.UsedAt == null)
.FirstOrDefaultAsync();
if (token == null) return null;
token.UsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return token.UserId;
}
public async Task<List<string>> ResetMfaForUserAsync(string userId)
{
var user = await _db.Users.FindAsync(userId)
?? throw new InvalidOperationException("User not found");
user.TotpEnabled = false;
user.TotpSecret = null;
_db.WebAuthnCredentials.RemoveRange(
_db.WebAuthnCredentials.Where(c => c.UserId == userId));
_db.BackupCodes.RemoveRange(
_db.BackupCodes.Where(c => c.UserId == userId));
var newCodes = await _backupCodeService.GenerateBackupCodesAsync(userId);
_db.AuditLogs.Add(new AuditLog
{
UserId = userId,
Action = "MFA_RESET_RECOVERY",
Severity = "HIGH",
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
return newCodes;
}
private static string Sha256Hex(string input)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLower();
}
}Recovery Endpoint
app.post('/api/auth/recovery/initiate', async (req: Request, res: Response) => {
try {
const { email } = req.body;
await initiateAccountRecovery(email);
res.json({
success: true,
message: 'Recovery instructions sent to email'
});
} catch (error) {
res.status(500).json({ error: 'Recovery initiation failed' });
}
});
app.post('/api/auth/recovery/verify', async (req: Request, res: Response) => {
try {
const { code } = req.body;
const userId = await verifyRecoveryCode(code);
if (!userId) {
return res.status(400).json({ error: 'Invalid or expired recovery code' });
}
// Reset MFA and get new backup codes
const newCodes = await resetMFAForUser(userId);
res.json({
success: true,
message: 'MFA has been reset',
backupCodes: newCodes,
warning: 'Save these codes immediately. They are only shown once.'
});
} catch (error) {
res.status(500).json({ error: 'Recovery verification failed' });
}
});@RestController
@RequestMapping("/api/auth/recovery")
public class RecoveryController {
private final AccountRecoveryService recoveryService;
public RecoveryController(AccountRecoveryService recoveryService) {
this.recoveryService = recoveryService;
}
@PostMapping("/initiate")
public ResponseEntity<?> initiateRecovery(@RequestBody Map<String, String> body) {
try {
recoveryService.initiateAccountRecovery(body.get("email"));
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Recovery instructions sent to email"
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "Recovery initiation failed"));
}
}
@PostMapping("/verify")
public ResponseEntity<?> verifyRecovery(@RequestBody Map<String, String> body) {
try {
Optional<String> userId = recoveryService.verifyRecoveryCode(body.get("code"));
if (userId.isEmpty()) {
return ResponseEntity.status(400).body(Map.of("error", "Invalid or expired recovery code"));
}
List<String> newCodes = recoveryService.resetMFAForUser(userId.get());
return ResponseEntity.ok(Map.of(
"success", true,
"message", "MFA has been reset",
"backupCodes", newCodes,
"warning", "Save these codes immediately. They are only shown once."
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", "Recovery verification failed"));
}
}
}from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from database import get_db
router = APIRouter(prefix="/api/auth/recovery")
class InitiateRecoveryBody(BaseModel):
email: str
class VerifyRecoveryBody(BaseModel):
code: str
@router.post("/initiate")
async def initiate_recovery(body: InitiateRecoveryBody, db: Session = Depends(get_db)):
initiate_account_recovery(body.email, db)
return {"success": True, "message": "Recovery instructions sent to email"}
@router.post("/verify")
async def verify_recovery(body: VerifyRecoveryBody, db: Session = Depends(get_db)):
user_id = verify_recovery_code(body.code, db)
if not user_id:
raise HTTPException(status_code=400, detail="Invalid or expired recovery code")
new_codes = reset_mfa_for_user(user_id, db)
return {
"success": True,
"message": "MFA has been reset",
"backup_codes": new_codes,
"warning": "Save these codes immediately. They are only shown once.",
}[ApiController]
[Route("api/auth/recovery")]
public class RecoveryController : ControllerBase
{
private readonly AccountRecoveryService _recoveryService;
public RecoveryController(AccountRecoveryService recoveryService) =>
_recoveryService = recoveryService;
[HttpPost("initiate")]
public async Task<IActionResult> InitiateRecovery([FromBody] InitiateRecoveryRequest request)
{
await _recoveryService.InitiateAccountRecoveryAsync(request.Email);
return Ok(new { success = true, message = "Recovery instructions sent to email" });
}
[HttpPost("verify")]
public async Task<IActionResult> VerifyRecovery([FromBody] VerifyRecoveryRequest request)
{
var userId = await _recoveryService.VerifyRecoveryCodeAsync(request.Code);
if (userId == null)
return BadRequest(new { error = "Invalid or expired recovery code" });
var newCodes = await _recoveryService.ResetMfaForUserAsync(userId);
return Ok(new
{
success = true,
message = "MFA has been reset",
backupCodes = newCodes,
warning = "Save these codes immediately. They are only shown once."
});
}
}Defending Against MFA Attacks
MFA Fatigue (Push Bombing)
Attackers send dozens of push notifications hoping you’ll accept one in frustration.
// Rate limit push notifications
const pushNotificationLimiter = new RateLimiter({
maxConcurrent: 1,
minTime: 5000, // 5 seconds between pushes
reservoir: 5,
reservoirRefreshAmount: 5,
reservoirRefreshInterval: 60 * 60 * 1000 // 5 per hour
});
export async function sendMFAPushNotification(userId: string, context: {
ipAddress: string;
deviceName: string;
}) {
try {
await pushNotificationLimiter.schedule(async () => {
const notification = {
userId,
title: 'Login Request',
body: `Login attempt from ${context.deviceName}`,
action: `https://yourdomain.com/approve-login/${generateToken()}`,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
approved: false
};
await sendPushNotification(userId, notification);
// Log for fatigue detection
await prisma.mfaPushLog.create({
data: {
userId,
sentAt: new Date(),
ipAddress: context.ipAddress
}
});
});
} catch (error) {
if (error instanceof Error && error.message.includes('Rate limit exceeded')) {
// Alert: potential MFA fatigue attack
await alertSecurityTeam({
severity: 'HIGH',
message: `MFA fatigue detected for user ${userId}`
});
}
}
}
// Detect fatigue patterns
export async function detectMFAFatigue(userId: string): Promise<boolean> {
const recentPushes = await prisma.mfaPushLog.count({
where: {
userId,
sentAt: {
gt: new Date(Date.now() - 15 * 60 * 1000) // Last 15 minutes
}
}
});
// Alert if more than 3 push attempts in 15 minutes
if (recentPushes > 3) {
await alertSecurityTeam({
severity: 'HIGH',
userId,
message: `Potential MFA fatigue attack detected (${recentPushes} pushes)`
});
// Require additional verification
return true;
}
return false;
}@Service
public class MfaFatigueService {
private final MfaPushLogRepository pushLogRepo;
private final PushNotificationService pushService;
private final SecurityAlertService alertService;
private final Map<String, Bucket> rateLimitBuckets = new ConcurrentHashMap<>();
public MfaFatigueService(MfaPushLogRepository pushLogRepo,
PushNotificationService pushService,
SecurityAlertService alertService) {
this.pushLogRepo = pushLogRepo;
this.pushService = pushService;
this.alertService = alertService;
}
// Rate limiter: max 5 pushes per userId per hour, min 5s between pushes
private Bucket getBucketForUser(String userId) {
return rateLimitBuckets.computeIfAbsent(userId, k ->
Bucket.builder()
.addLimit(Bandwidth.simple(5, Duration.ofHours(1)))
.build()
);
}
@Transactional
public void sendMfaPushNotification(String userId, String ipAddress, String deviceName) {
Bucket bucket = getBucketForUser(userId);
if (!bucket.tryConsume(1)) {
alertService.alert("HIGH", "MFA fatigue detected for user " + userId);
return;
}
MfaPushNotification notification = new MfaPushNotification(
userId,
"Login Request",
"Login attempt from " + deviceName,
"https://yourdomain.com/approve-login/" + generateToken(),
Instant.now().plusSeconds(5 * 60),
false
);
pushService.send(userId, notification);
MfaPushLog log = new MfaPushLog();
log.setUserId(userId);
log.setSentAt(Instant.now());
log.setIpAddress(ipAddress);
pushLogRepo.save(log);
}
public boolean detectMfaFatigue(String userId) {
long recentPushes = pushLogRepo.countByUserIdAndSentAtAfter(
userId, Instant.now().minusSeconds(15 * 60));
if (recentPushes > 3) {
alertService.alert("HIGH",
String.format("Potential MFA fatigue attack detected (%d pushes)", recentPushes));
return true;
}
return false;
}
}from datetime import datetime, timedelta, timezone
from collections import defaultdict
import threading
from sqlalchemy.orm import Session
from models import MfaPushLog
_rate_limit_lock = threading.Lock()
_push_timestamps: dict[str, list[datetime]] = defaultdict(list)
def _check_push_rate_limit(user_id: str, max_count: int = 5, window_hours: int = 1) -> bool:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(hours=window_hours)
with _rate_limit_lock:
times = [t for t in _push_timestamps[user_id] if t > cutoff]
if len(times) >= max_count:
return False
times.append(now)
_push_timestamps[user_id] = times
return True
def send_mfa_push_notification(
user_id: str, ip_address: str, device_name: str, db: Session
) -> None:
if not _check_push_rate_limit(user_id):
alert_security_team(severity="HIGH", message=f"MFA fatigue detected for user {user_id}")
return
notification = {
"user_id": user_id,
"title": "Login Request",
"body": f"Login attempt from {device_name}",
"action": f"https://yourdomain.com/approve-login/{generate_token()}",
"expires_at": datetime.now(timezone.utc) + timedelta(minutes=5),
"approved": False,
}
send_push_notification(user_id, notification)
db.add(MfaPushLog(
user_id=user_id,
sent_at=datetime.now(timezone.utc),
ip_address=ip_address,
))
db.commit()
def detect_mfa_fatigue(user_id: str, db: Session) -> bool:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=15)
recent_pushes = db.query(MfaPushLog).filter(
MfaPushLog.user_id == user_id,
MfaPushLog.sent_at > cutoff,
).count()
if recent_pushes > 3:
alert_security_team(
severity="HIGH",
user_id=user_id,
message=f"Potential MFA fatigue attack detected ({recent_pushes} pushes)",
)
return True
return Falsepublic class MfaFatigueService
{
private readonly AppDbContext _db;
private readonly IPushNotificationService _pushService;
private readonly ISecurityAlertService _alertService;
private readonly ConcurrentDictionary<string, List<DateTime>> _rateLimits = new();
public MfaFatigueService(AppDbContext db, IPushNotificationService pushService,
ISecurityAlertService alertService)
{
_db = db;
_pushService = pushService;
_alertService = alertService;
}
private bool CheckPushRateLimit(string userId, int maxCount = 5, int windowHours = 1)
{
var now = DateTime.UtcNow;
var cutoff = now.AddHours(-windowHours);
var times = _rateLimits.GetOrAdd(userId, _ => new List<DateTime>());
lock (times)
{
times.RemoveAll(t => t < cutoff);
if (times.Count >= maxCount) return false;
times.Add(now);
}
return true;
}
public async Task SendMfaPushNotificationAsync(string userId, string ipAddress, string deviceName)
{
if (!CheckPushRateLimit(userId))
{
await _alertService.AlertAsync("HIGH", $"MFA fatigue detected for user {userId}");
return;
}
var notification = new MfaPushNotification
{
UserId = userId,
Title = "Login Request",
Body = $"Login attempt from {deviceName}",
Action = $"https://yourdomain.com/approve-login/{GenerateToken()}",
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
Approved = false
};
await _pushService.SendAsync(userId, notification);
_db.MfaPushLogs.Add(new MfaPushLog
{
UserId = userId,
SentAt = DateTime.UtcNow,
IpAddress = ipAddress
});
await _db.SaveChangesAsync();
}
public async Task<bool> DetectMfaFatigueAsync(string userId)
{
var cutoff = DateTime.UtcNow.AddMinutes(-15);
int recentPushes = await _db.MfaPushLogs
.CountAsync(l => l.UserId == userId && l.SentAt > cutoff);
if (recentPushes > 3)
{
await _alertService.AlertAsync("HIGH",
$"Potential MFA fatigue attack detected ({recentPushes} pushes)");
return true;
}
return false;
}
}Preventing Phishing of MFA Codes
export async function validateMFACodeContext(
userId: string,
code: string,
context: { ipAddress: string; userAgent: string }
) {
// Check if code was requested from same IP/device
const latestChallenge = await prisma.mfaChallenge.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' }
});
if (!latestChallenge) {
throw new Error('No active MFA challenge');
}
// Check if code is being submitted from different IP
if (latestChallenge.initiatorIP !== context.ipAddress) {
await prisma.auditLog.create({
data: {
userId,
action: 'MFA_CODE_FROM_DIFFERENT_IP',
severity: 'HIGH',
details: JSON.stringify({
initiatedFrom: latestChallenge.initiatorIP,
submittedFrom: context.ipAddress
}),
timestamp: new Date()
}
});
// Reject and alert
throw new Error('MFA code submitted from different location');
}
return true;
}@Service
public class MfaPhishingProtectionService {
private final MfaChallengeRepository challengeRepo;
private final AuditLogRepository auditLogRepo;
public MfaPhishingProtectionService(MfaChallengeRepository challengeRepo,
AuditLogRepository auditLogRepo) {
this.challengeRepo = challengeRepo;
this.auditLogRepo = auditLogRepo;
}
public void validateMfaCodeContext(String userId, String ipAddress) {
MfaChallenge latestChallenge = challengeRepo
.findFirstByUserIdOrderByCreatedAtDesc(userId)
.orElseThrow(() -> new RuntimeException("No active MFA challenge"));
if (!latestChallenge.getInitiatorIP().equals(ipAddress)) {
AuditLog log = new AuditLog();
log.setUserId(userId);
log.setAction("MFA_CODE_FROM_DIFFERENT_IP");
log.setSeverity("HIGH");
log.setDetails(String.format(
"{\"initiatedFrom\":\"%s\",\"submittedFrom\":\"%s\"}",
latestChallenge.getInitiatorIP(), ipAddress));
log.setTimestamp(Instant.now());
auditLogRepo.save(log);
throw new RuntimeException("MFA code submitted from different location");
}
}
}from sqlalchemy.orm import Session
from models import MfaChallenge, AuditLog
import json
from datetime import datetime, timezone
def validate_mfa_code_context(
user_id: str, ip_address: str, db: Session
) -> bool:
latest_challenge = (
db.query(MfaChallenge)
.filter(MfaChallenge.user_id == user_id)
.order_by(MfaChallenge.created_at.desc())
.first()
)
if not latest_challenge:
raise ValueError("No active MFA challenge")
if latest_challenge.initiator_ip != ip_address:
db.add(AuditLog(
user_id=user_id,
action="MFA_CODE_FROM_DIFFERENT_IP",
severity="HIGH",
details=json.dumps({
"initiated_from": latest_challenge.initiator_ip,
"submitted_from": ip_address,
}),
timestamp=datetime.now(timezone.utc),
))
db.commit()
raise ValueError("MFA code submitted from different location")
return Truepublic class MfaPhishingProtectionService
{
private readonly AppDbContext _db;
public MfaPhishingProtectionService(AppDbContext db) => _db = db;
public async Task ValidateMfaCodeContextAsync(string userId, string ipAddress)
{
var latestChallenge = await _db.MfaChallenges
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.FirstOrDefaultAsync()
?? throw new InvalidOperationException("No active MFA challenge");
if (latestChallenge.InitiatorIP != ipAddress)
{
_db.AuditLogs.Add(new AuditLog
{
UserId = userId,
Action = "MFA_CODE_FROM_DIFFERENT_IP",
Severity = "HIGH",
Details = System.Text.Json.JsonSerializer.Serialize(new
{
initiatedFrom = latestChallenge.InitiatorIP,
submittedFrom = ipAddress
}),
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
throw new InvalidOperationException("MFA code submitted from different location");
}
}
}Production Checklist
- Secret Management: TOTP secrets stored encrypted at rest, never in logs
- Timing-Safe Comparisons: Use
crypto.timingSafeEqual()for code verification - Rate Limiting: Maximum attempts with exponential backoff on failures
- Audit Logging: Every MFA action logged with timestamp, IP, device, outcome
- Challenge Expiration: All temporary challenges expire after 10-15 minutes
- Secure Channel: Use HTTPS only, no MFA codes in URLs
- Device Tracking: Recognize trusted devices, require MFA less frequently
- Recovery Paths: Backup codes, recovery tokens, support procedures
- Backup Code Rotation: Generate fresh codes after account recovery
- Security Key Attestation: Verify hardware key legitimacy (optional but recommended)
- Counter Checks: WebAuthn credential counters prevent cloned keys
- Session Binding: MFA-verified sessions tied to specific device/IP
- Abuse Detection: Alert on multiple failed attempts, impossible travel, unusual patterns
- Incident Response: Clear procedures for security key compromise, account takeover
Compliance Considerations
PCI-DSS (Payment Card Industry)
- Requires MFA for any access to cardholder data
- Must use “strong cryptography” (excludes SMS-only)
- Recommend TOTP or hardware keys
HIPAA (Healthcare)
- Requires MFA for administrative access
- Must use “approved” second factors
- Extensive audit logging required
SOC2 (Service Organization Control)
- MFA for all system administrators
- Regular review and testing of MFA mechanisms
- Incident logging for MFA-related events
GDPR (General Data Protection Regulation)
- MFA reduces liability for data breaches
- Document MFA mechanisms in privacy documentation
- Backup codes must be securely stored/destroyed
Conclusion
Implementing robust MFA requires thoughtful layering: TOTP for accessibility, hardware keys for security-conscious users, SMS as a fallback, and adaptive MFA to keep friction low. Always provide recovery paths—the perfect security system that locks users out forever serves no one. The goal is defense in depth: make compromising a single factor insufficient, and you’ve won half the battle.