Skip to main content

How MFA Works

Add a second layer of security beyond passwords. After users authenticate with their credentials, they verify their identity using a second factor. This guide covers the shared MFA architecture, configuration, and endpoints that apply to all methods. The method-specific guides cover setup and login for each type.

Supported Methods

MethodUser ExperienceSecurityBest For
Email6-digit code via emailMediumOnboarding, backup option
SMS6-digit code via text messageMediumUsers without authenticator apps
TOTPCode from authenticator app (Google Authenticator, Authy)HighMost users (offline, no cost)
PasskeyBiometric (Face ID, Touch ID) or security key (YubiKey)Very HighModern devices, phishing-resistant

Users can enroll multiple devices per method (e.g., two TOTP apps, or laptop Touch ID + phone Face ID). Each device is managed independently.

Sample apps

MFA is implemented in the nauth example apps — see the NestJS, Express, and Fastify examples for MFA management routes, challenge helpers, and the Angular example for mfa-setup.component.ts, otp-verify.component.ts, and passkey-setup.component.ts.

Prerequisites

You have completed the Basic Auth Flows guide and have working signup, login, and challenge endpoints.

Configuration

import { MFAMethod } from '@nauth-toolkit/core';

mfa: {
enabled: true,
enforcement: 'OPTIONAL', // OPTIONAL | REQUIRED | ADAPTIVE
allowedMethods: [
MFAMethod.EMAIL,
MFAMethod.SMS,
MFAMethod.TOTP,
MFAMethod.PASSKEY,
],

// Device trust — lets users skip MFA on recognized devices
rememberDevices: 'user_opt_in', // 'always' | 'user_opt_in' | 'never'
rememberDeviceDays: 30,
bypassMFAForTrustedDevices: true,

// Grace period for REQUIRED enforcement (days before enforcement kicks in)
gracePeriod: 7,

// Skip MFA for social login users
requireForSocialLogin: false,

// TOTP settings
issuer: 'YourAppName', // Shown in authenticator apps
totp: {
window: 1, // Allow ±1 time step (30s tolerance)
stepSeconds: 30,
digits: 6,
algorithm: 'sha1',
},

// Passkey (WebAuthn) settings
passkey: {
rpName: 'Your App Name',
rpId: 'yourdomain.com',
origin: ['https://yourdomain.com'],
timeout: 60000,
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
},

See Configuration > MFA for the full reference.

Enforcement Modes

ModeBehaviorBest For
OPTIONALUsers can enable MFA in settings. MFA only checked if enrolled.Consumer apps
REQUIREDAll users must set up MFA. Login returns MFA_SETUP_REQUIRED until they do. Grace period configurable.Enterprise, compliance
ADAPTIVESame setup requirement as REQUIRED. After enrollment, MFA challenges are only triggered by risk signals (new device, new country, impossible travel).Optimal security/UX balance

REQUIRED enforcement prompts users to set up MFA after login. During the grace period (gracePeriod days), users can log in without MFA. After the grace period, login is blocked until MFA is configured.

ADAPTIVE enforcement has the same setup requirement as REQUIRED — users must enroll MFA (subject to the same grace period). The difference is what happens after enrollment: instead of always challenging, the risk engine evaluates each login and only triggers MFA when risk is elevated. See Adaptive MFA below.

How MFA Works

Login with MFA (already set up)

  1. Login returns MFA_REQUIRED with availableMethods and preferredMethod
  2. For code-based methods (email, SMS), a code is sent automatically
  3. For TOTP, the user opens their authenticator app
  4. For Passkey, the frontend calls WebAuthn browser API
  5. User completes the challenge via /auth/respond-challenge

Forced setup (REQUIRED enforcement)

  1. Login returns MFA_SETUP_REQUIRED with allowedMethods
  2. Frontend calls /auth/challenge/setup-data with the chosen method
  3. For email/SMS: if already verified, setup auto-completes; otherwise a code is sent
  4. For TOTP: a QR code and manual entry key are returned
  5. For Passkey: WebAuthn registration options are returned
  6. User completes setup via /auth/respond-challenge

Self-service setup (from security settings)

  1. Authenticated user calls POST /auth/mfa/setup-data with the method
  2. Method-specific setup data is returned
  3. User verifies with POST /auth/mfa/verify-setup
  4. MFA device is registered

Shared Endpoints

These endpoints are the same regardless of MFA method.

MFA Status

Check whether the current user has MFA enabled and which methods are enrolled.

src/auth/mfa.controller.ts
import { Controller, Get, Post, Delete, Body, Param, UseGuards, HttpCode, HttpStatus, Inject } from '@nestjs/common';
import { AuthGuard, MFAService } from '@nauth-toolkit/nestjs';
import { Optional, Inject } from '@nestjs/common';

@UseGuards(AuthGuard)
@Controller('auth/mfa')
export class MfaController {
constructor(
@Optional() @Inject(MFAService)
private readonly mfaService?: MFAService,
) {}

@Get('status')
async getStatus() {
return await this.mfaService!.getMfaStatus();
}
}

Response:

{
"enabled": false,
"required": false,
"configuredMethods": [],
"availableMethods": ["email", "sms", "totp", "passkey"],
"hasBackupCodes": false,
"mfaExempt": false,
"mfaExemptReason": null,
"mfaExemptGrantedAt": null
}

Setup and Verify (Self-Service)

All methods use the same two endpoints. Only the request/response payloads differ.

src/auth/mfa.controller.ts
// Inside MfaController class:

@Post('setup-data')
@HttpCode(HttpStatus.OK)
async setup(@Body() dto: SetupMFADTO) {
return await this.mfaService.setup(dto);
}

@Post('verify-setup')
@HttpCode(HttpStatus.OK)
async verifySetup(@Body() dto: SetupMFADTO) {
const provider = this.mfaService.getProvider(dto.methodName);
const deviceId = await provider.verifySetup(dto.setupData);
return { deviceId };
}

See each method-specific guide for the request/response payloads: Email, SMS, TOTP, Passkey.

Challenge Helpers (Login Flow)

During login, these public endpoints support the MFA challenge:

src/auth/auth.controller.ts
// Already in AuthController from Basic Auth guide:

@Public()
@Post('challenge/setup-data')
@HttpCode(HttpStatus.OK)
async getSetupData(@Body() dto: GetSetupDataDTO): Promise<GetSetupDataResponseDTO> {
if (!this.mfaService) throw new BadRequestException('MFA service is not available');
return await this.mfaService.getSetupData(dto);
}

// For Passkey — get WebAuthn assertion options:
@Public()
@Post('challenge/challenge-data')
@HttpCode(HttpStatus.OK)
async getChallengeData(@Body() dto: any) {
if (!this.mfaService) throw new BadRequestException('MFA service is not available');
return await this.mfaService.getChallengeData(dto);
}
  • challenge/setup-data — used during MFA_SETUP_REQUIRED (forced setup at login)
  • challenge/challenge-data — used during MFA_REQUIRED for Passkey (WebAuthn assertion options), SMS (sends code, returns masked phone), or Email (sends code, returns masked email)

Device Management

src/auth/mfa.controller.ts
// Inside MfaController class:

@Get('devices')
async getDevices() {
return await this.mfaService.getUserDevices({});
}

@Post('devices/:deviceId/preferred')
@HttpCode(HttpStatus.OK)
async setPreferred(@Param('deviceId') deviceId: string) {
return await this.mfaService.setPreferredDevice({ deviceId } as any);
}

@Delete('devices/:deviceId')
@HttpCode(HttpStatus.OK)
async removeDevice(@Param('deviceId') deviceId: string) {
return await this.mfaService.removeDevice({ deviceId } as any);
}

List devices response:

{
"devices": [
{
"id": 42,
"type": "email",
"name": "j***@example.com",
"isPreferred": true,
"isActive": true,
"createdAt": "2025-01-10T08:00:00.000Z"
}
]
}
Last device removal

If the user removes their only MFA device, MFA is automatically disabled. If enforcement: 'REQUIRED', they'll be prompted to set up MFA again on the next login.

Frontend Integration

The Angular example app handles MFA with dedicated components:

otp-verify.component.ts — handles MFA_REQUIRED challenges. Detects the MFA method and adjusts UI accordingly:

Angular — otp-verify.component.ts (simplified)
import { getMFAMethod, AuthChallenge, type MFACodeResponse } from '@nauth-toolkit/client';

// In component:
readonly challenge = this.auth.challenge; // signal
readonly mfaMethod = computed(() => getMFAMethod(this.challenge())); // 'email' | 'sms' | 'totp' | 'passkey'
readonly isTotp = computed(() => this.mfaMethod() === 'totp');

// TOTP: "Enter code from your authenticator app"
// Email/SMS: "We sent a code to j***@example.com"
// Passkey: handled by passkey-setup.component.ts

async onSubmit(code: string): Promise<void> {
const response = await this.auth.respondToChallenge({
type: AuthChallenge.MFA_REQUIRED,
session: this.challenge().session,
method: this.mfaMethod(),
code,
} as MFACodeResponse);

this.orchestrator.handleAuthResponse(response);
}

mfa-setup.component.ts — handles MFA_SETUP_REQUIRED with method selection and auto-completion detection:

Angular — mfa-setup.component.ts (simplified)
async selectMethod(method: 'email' | 'sms'): Promise<void> {
const { setupData } = await this.auth.getSetupData(this.challenge().session, method);

if (setupData.autoCompleted) {
// Email/phone already verified — complete immediately
await this.auth.respondToChallenge({
type: AuthChallenge.MFA_SETUP_REQUIRED,
session: this.challenge().session,
method,
setupData: { deviceId: setupData.deviceId },
});
this.router.navigate(['/dashboard']);
} else {
// Code sent — navigate to OTP entry
this.router.navigate(['/auth/challenge/mfa-setup-required/verify'], {
queryParams: { method },
});
}
}

The challenge orchestrator routes MFA_SETUP_REQUIRED to the setup component and MFA_REQUIRED to the OTP or passkey component based on preferredMethod.

Adaptive MFA

When enforcement: 'ADAPTIVE', the risk engine evaluates each login and decides whether to require MFA:

Risk FactorDefault PointsDescription
new_device25First login from unknown device fingerprint
new_ip15Login from new IP address (only if country unchanged)
new_country25Login from different country than usual
impossible_travel40Physically impossible travel (e.g., Tokyo to NYC in 1 hour)
suspicious_activity30Recent failed logins or security events
recent_password_reset40Password changed after last successful login

Risk scores (0-100) map to actions:

ScoreDefault ActionBehavior
0-20 (Low)allowProceed without MFA
21-50 (Medium)require_mfaMFA challenge required
51-100 (High)require_mfaMFA required (configure block_signin to block outright)
mfa: {
enforcement: 'ADAPTIVE',
adaptive: {
triggers: ['new_device', 'new_country', 'impossible_travel', 'recent_password_reset'],

// Optional: customize risk weights
riskWeights: { impossible_travel: 100 },

// Optional: customize thresholds and actions
riskLevels: {
low: { maxScore: 20, action: 'allow', notifyUser: false },
medium: { maxScore: 50, action: 'require_mfa', notifyUser: true },
high: { maxScore: 100, action: 'require_mfa', notifyUser: true }, // 'block_signin' for stricter policy
},
},
},

Location-based triggers (new_country, impossible_travel) require Geolocation configured.

Smart deduplication

The system excludes new_ip when new_country or impossible_travel is detected to prevent double-counting.

Profile Changes and MFA

When a user updates their email or phone number, associated MFA devices are permanently deleted for security:

Profile ChangeMFA Impact
Email updatedAll Email MFA devices deleted
Phone updatedAll SMS MFA devices deleted

If the deleted device(s) were the user's only MFA method, MFA is automatically disabled. With enforcement: 'REQUIRED', they'll be prompted to re-enroll on the next login.

All MFA device deletions are logged with event type MFA_DEVICE_REMOVED:

{
"eventType": "MFA_DEVICE_REMOVED",
"userId": "user-sub",
"reason": "email_changed",
"metadata": {
"devicesDeleted": 1,
"mfaDisabled": false
}
}

Error Codes

Error CodeReasonUser Action
MFA_REQUIREDLogin requires MFAPrompt for MFA code
MFA_INVALID_CODECode is incorrectTry again
MFA_EXPIRED_CODECode expired (5-10 min)Request new code
MFA_NO_DEVICESMFA enabled but no devices enrolledContact support
MFA_CHALLENGE_EXPIREDMFA challenge timed outRe-login
MFA_SETUP_INVALIDSetup verification failedRe-scan QR / check time sync
MFA_METHOD_NOT_ALLOWEDMethod not enabled in configUse different method
MFA_ALREADY_SETUPMethod already enrolledRemove old device first
SIGNIN_BLOCKED_HIGH_RISKAdaptive MFA blocked loginWait or contact support

What's Next

Choose a method to implement:

  • Email MFA — Simplest to set up, uses your existing email provider
  • SMS MFA — Requires an SMS provider (AWS SNS)
  • TOTP MFA — Most popular, works offline, QR code setup
  • Passkey MFA — Most secure, biometric/hardware key authentication