Back to KB
Difficulty
Intermediate
Read Time
8 min

Multi-factor authentication

By Codcompass Team¡¡8 min read

Multi-factor Authentication: Engineering Resilient Identity Systems

Current Situation Analysis

The industry pain point is no longer the absence of Multi-factor Authentication (MFA); it is the prevalence of insecure, friction-heavy, and easily bypassed MFA implementations. While 82% of breaches involve the human element, a significant portion of organizations rely on deprecated factors that fail against modern attack vectors.

Developers and security architects frequently treat MFA as a compliance checkbox rather than a cryptographic control. This leads to widespread reliance on SMS-based OTPs, which are vulnerable to SIM swapping, SS7 protocol exploits, and social engineering at the carrier level. Furthermore, the rise of MFA fatigue attacks—where attackers bombard users with push notifications until they approve access—exposes the weakness of "push-to-approve" mechanisms that lack context-aware verification.

The problem is often misunderstood as a user education issue. While training helps, the technical implementation dictates the security ceiling. If the backend allows replay attacks, fails to bind the authenticator to the specific relying party, or stores backup codes in plaintext, no amount of user vigilance prevents compromise.

Data-backed evidence:

  • Verizon's 2024 Data Breach Investigations Report indicates that 74% of breaches involve the human element, yet Microsoft research demonstrates that MFA can block 99.9% of account compromise attacks. The gap lies in the type of MFA; NIST SP 800-63B explicitly deprecates SMS for high-assurance scenarios due to interception risks.
  • A 2023 study by the FIDO Alliance found that phishing-resistant MFA reduced account takeover rates by 99.99% compared to non-phishing-resistant methods, highlighting that not all MFA is created equal.

WOW Moment: Key Findings

The critical insight for engineering teams is that MFA effectiveness correlates directly with phishing resistance and cryptographic binding, not merely the presence of a second factor. SMS and TOTP provide "something you have," but they do not cryptographically bind the authentication to the origin, leaving them susceptible to man-in-the-middle (MitM) attacks.

The following comparison illustrates why FIDO2/WebAuthn is the only viable path for high-security applications, despite higher implementation complexity.

ApproachPhishing ResistanceNIST 800-63B AALImplementation ComplexityUser FrictionReplay Attack Risk
SMS OTPLowAAL2 (Deprecated)LowHighHigh
TOTPMediumAAL2MediumMediumMedium
Push NotificationLow-MediumAAL2MediumLowLow (with binding)
FIDO2 / WebAuthnHighAAL3HighLowNone

Why this matters: Organizations implementing TOTP or SMS often believe they are secure. However, sophisticated phishing kits can harvest TOTP codes in real-time via reverse proxy attacks. FIDO2's origin binding ensures that the private key never leaves the authenticator and the signature is valid only for the specific domain, rendering phishing attempts cryptographically impossible.

Core Solution

Implementing production-grade MFA requires a phased approach prioritizing FIDO2/WebAuthn with secure fallbacks. This section outlines the architecture and implementation for a phishing-resistant MFA system using TypeScript.

1. Architecture Decisions

  • Authenticator Selection: Prioritize platform authenticators (TouchID, Windows Hello, Android Biometrics) and cross-platform security keys (YubiKey). TOTP is acceptable only as a recovery fallback, never as a primary factor for high-risk actions.
  • Session Binding: MFA should not result in a permanent session elevation. Implement step-up authentication where sensitive operations require a fresh MFA assertion within a short window (e.g., 5 minutes).
  • Challenge Management: Never use static challenges. The server must generate a cryptographically random challenge for each registration and authentication attempt, storing it in a secure, short-lived cache (e.g., Redis with TTL) bound to the user session.

2. Technical Implementation

We use the @simplewebauthn/server library for backend logic and the Web Authentication API for the frontend. This approach adheres to the W3C WebAuthn Level 3 specification.

Backend: Registration Flow

The registration process creates a credential bound to the user. The server generates options, the client creates the credential, and the server verifies the attestation.

import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { Base64URLString } from '@simplewebauthn/types';
import { v4 as uuidv4 } from 'uuid';
import { redis } from './redis-client';

// 1. Generate Registration Options
export async function startRegistration(userId: string, userName: string) {
  const options = await generateRegistrationOptions({
    rpName: 'Codcompass',
    rpID: 'app.codcompass.io',
    userID: Buffer.from(uuidv4(), 'utf-8'),
    userName,
    attestationType: 'none', // Use 'direct' or 'indirect' for enterprise key management
    authenticatorSelection: {
      authenticatorAttachment: 'platform', // Prefer platform biometrics
      userVerification: 'required', // Enforce biometric/PIN
    },
  });

  // Store challenge in Redis with 5-minute TTL to prevent replay
  await redis.set(`mfa:challenge:${options.challenge}`, JSON.stringify({ userId, options }), { EX: 300 });

  return options;
}

// 2. Verify Registration Response
export async function finishRegistration(
  userId: string,
  response: RegistrationResponseJSON,
) {
  const challengeKey = `mfa:challenge:${response.response.clientDataJSON}`;
  const storedData = await redis.get(challengeKey);
  
  if (!storedData) throw new Error('Challenge expired or missing');

  const { options } = JSON.parse(storedData);

  const verification = await verifyRegistrationResponse({
    response,
    expe

ctedChallenge: options.challenge, expectedOrigin: 'https://app.codcompass.io', expectedRPID: 'app.codcompass.io', });

if (!verification.verified) { throw new Error('Registration verification failed'); }

// Store credential ID and public key in database await db.users.updateCredential(userId, { credentialID: verification.registrationInfo!.credential.id, publicKey: verification.registrationInfo!.credential.publicKey, counter: verification.registrationInfo!.credential.counter, });

await redis.del(challengeKey); return { verified: true }; }


**Backend: Authentication Flow**

Authentication verifies the user possesses the private key corresponding to the registered public key.

```typescript
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';

export async function startAuthentication(userId: string) {
  const userCredentials = await db.users.getCredentials(userId);
  
  const options = await generateAuthenticationOptions({
    rpID: 'app.codcompass.io',
    allowCredentials: userCredentials.map(c => ({
      id: c.credentialID,
      type: 'public-key',
    })),
  });

  await redis.set(`mfa:auth_challenge:${options.challenge}`, userId, { EX: 300 });

  return options;
}

export async function finishAuthentication(
  userId: string,
  response: AuthenticationResponseJSON,
) {
  const credential = await db.users.getCredential(response.id);
  if (!credential) throw new Error('Credential not found');

  const storedUserId = await redis.get(`mfa:auth_challenge:${response.response.clientDataJSON}`);
  if (storedUserId !== userId) throw new Error('Challenge mismatch');

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: response.response.clientDataJSON, // Extract challenge from clientData
    expectedOrigin: 'https://app.codcompass.io',
    expectedRPID: 'app.codcompass.io',
    authenticator: {
      credentialPublicKey: credential.publicKey,
      credentialID: credential.credentialID,
      counter: credential.counter,
    },
  });

  if (!verification.verified) throw new Error('Authentication failed');

  // Update counter to prevent replay
  await db.users.updateCounter(userId, verification.authenticationInfo!.newCounter);
  
  // Issue MFA-verified session token
  return { verified: true, sessionToken: generateSecureToken() };
}

3. Recovery Mechanism

Backup codes must be implemented securely. Common mistakes include storing codes in plaintext or allowing unlimited verification attempts.

  • Generation: Generate codes using a CSPRNG. Format as XXXX-XXXX-XXXX.
  • Storage: Hash codes using bcrypt or Argon2 before storing. Store the count of remaining uses.
  • Verification: When a user enters a code, iterate through stored hashes. If a match is found, verify the code, delete the hash from the database, and increment the usage counter. If the counter reaches the limit, revoke the backup method.

Pitfall Guide

  1. SMS as Primary Factor: SMS is vulnerable to SIM swapping and interception. NIST SP 800-63B restricts SMS to AAL2 only when no other factor is available, and it should never be used for high-value transactions.

    • Best Practice: Use FIDO2 for primary auth. If SMS is required for onboarding, force migration to an authenticator app or security key within 24 hours.
  2. Insecure Challenge Handling: Failing to bind the challenge to the user session or allowing challenges to persist indefinitely enables replay attacks.

    • Best Practice: Store challenges in a server-side cache with a strict TTL (e.g., 60 seconds for auth, 5 minutes for registration). Invalidate challenges immediately upon use.
  3. Ignoring Attestation in Enterprise: In corporate environments, allowing users to register personal devices without verification can introduce risk.

    • Best Practice: Use attestation verification to ensure credentials are generated on approved hardware models. Reject registrations from emulators or unverified authenticators.
  4. Weak Backup Code Storage: Storing backup codes in plaintext in the database means a database breach compromises all MFA protections.

    • Best Practice: Hash backup codes. Implement rate limiting on backup code entry. Invalidate codes after use.
  5. MFA Fatigue and Push Spam: Relying solely on push notifications without context allows attackers to spam users.

    • Best Practice: Implement "Number Matching" in push notifications, requiring the user to enter a number displayed on the login screen. Add geolocation and device context to push prompts.
  6. Counter Desynchronization: WebAuthn authenticators use a signature counter to detect cloned devices. Ignoring counter updates allows replay of old signatures.

    • Best Practice: Update the stored counter after every successful authentication. Reject authentications where the counter does not advance, unless handling a known edge case with a grace period.
  7. Session Management Gaps: MFA verifies identity at login, but if the session token is stolen, the attacker gains access without re-authenticating.

    • Best Practice: Bind session tokens to the MFA assertion. Implement short-lived access tokens and require step-up MFA for sensitive actions (e.g., changing email, password reset, financial transfers).

Production Bundle

Action Checklist

  • Audit Authenticator Types: Identify all current MFA methods. Flag SMS and TOTP as primary factors for migration to FIDO2.
  • Implement FIDO2/WebAuthn: Deploy registration and authentication flows using a compliant library. Enforce userVerification: 'required'.
  • Secure Backup Codes: Implement hashed backup code generation and verification. Ensure codes are single-use and rate-limited.
  • Add Step-Up Auth: Configure sensitive endpoints to require a fresh MFA assertion within the last 5 minutes.
  • Configure Attestation: Decide on attestation policy. For enterprise, enforce direct attestation; for consumer, use none or indirect to maximize compatibility.
  • Test Recovery Flows: Perform chaos engineering tests to verify users can recover access without compromising security.
  • Monitor Anomalies: Set up alerts for impossible travel, rapid credential registration, and backup code usage spikes.

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
Consumer SaaSFIDO2 (Platform) + TOTP FallbackBalances security with user device variety. Platform authenticators reduce friction.Low
Enterprise InternalFIDO2 with Attestation + Smart CardsEnsures only corporate-managed devices access resources. High assurance.Medium
Legacy MigrationTOTP + Push (with Number Matching)Legacy systems may not support WebAuthn. Push with number matching mitigates fatigue.Low
High-Frequency TradingFIDO2 + Hardware Keys + BiometricsZero trust. Hardware keys prevent malware-based theft. Biometrics ensure liveness.High
Regulated FinanceFIDO2 AAL3 + Step-Up + Audit LoggingMeets strict compliance requirements (e.g., FFIEC, PSD2).Medium

Configuration Template

Use this TypeScript configuration for @simplewebauthn/server to ensure secure defaults.

// webauthn.config.ts
import { AuthenticatorTransportFuture } from '@simplewebauthn/types';

export const webAuthnConfig = {
  rpName: 'YourAppName',
  rpID: 'yourapp.com',
  origin: 'https://yourapp.com',
  challengeTimeout: 60000, // 60 seconds
  userVerification: 'required', // Enforce biometric/PIN
  authenticatorSelection: {
    authenticatorAttachment: 'platform', // Prefer platform (TouchID/Windows Hello)
    requireResidentKey: false,
    userVerification: 'required',
  },
  // Allow USB/NFC keys as fallback
  allowedTransports: ['internal', 'hybrid'] as AuthenticatorTransportFuture[],
  
  // Attestation settings
  attestation: 'none', // Change to 'direct' for enterprise key management
  supportedAlgorithmIDs: [-7, -8, -257], // ES256, EdDSA, RS256
};

Quick Start Guide

  1. Install Dependencies:
    npm install @simplewebauthn/server @simplewebauthn/browser uuid
    
  2. Initialize Server: Add the registration and authentication endpoints to your API router using the code patterns in the Core Solution. Ensure Redis or equivalent cache is configured for challenge storage.
  3. Integrate Frontend: Add WebAuthn buttons to your login and settings pages. Use @simplewebauthn/browser to call startRegistration and startAuthentication, then pass the response to your backend.
  4. Verify and Test: Use the browser's developer tools to simulate WebAuthn. Test registration with platform authenticators and cross-platform keys. Verify that challenges expire and replay attacks are blocked.
  5. Deploy Recovery: Implement the backup code flow. Generate codes for a test user, hash them in the DB, and verify the recovery login process works end-to-end.

Sources

  • • ai-generated