All articles
Authentication Security Backend Node.js

Multi-Factor Authentication (MFA) Authentication Strategies for Modern Web Applications

Palakorn Voramongkol
April 13, 2025 13 min read

“A comprehensive implementation guide to multi-factor authentication — covering TOTP setup, backup codes, SMS verification, hardware security keys, adaptive MFA, and recovery strategies.”

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:

  1. Shared Secret: During setup, the server generates a random secret (usually 32 bytes) shared with the client
  2. Time Steps: The current Unix timestamp is divided into 30-second intervals
  3. HMAC Generation: HMAC-SHA1(secret, time_step) produces a hash
  4. 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 > threshold
public 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_codes
public 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 False
public 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 True
public 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.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading