ent 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,
expectedChallenge: 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.
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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Consumer SaaS | FIDO2 (Platform) + TOTP Fallback | Balances security with user device variety. Platform authenticators reduce friction. | Low |
| Enterprise Internal | FIDO2 with Attestation + Smart Cards | Ensures only corporate-managed devices access resources. High assurance. | Medium |
| Legacy Migration | TOTP + Push (with Number Matching) | Legacy systems may not support WebAuthn. Push with number matching mitigates fatigue. | Low |
| High-Frequency Trading | FIDO2 + Hardware Keys + Biometrics | Zero trust. Hardware keys prevent malware-based theft. Biometrics ensure liveness. | High |
| Regulated Finance | FIDO2 AAL3 + Step-Up + Audit Logging | Meets 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
- Install Dependencies:
npm install @simplewebauthn/server @simplewebauthn/browser uuid
- 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.
- 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.
- 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.
- 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.