Skip to main content

Email MFA

Add email-based multi-factor authentication. By the end of this guide you will have these endpoints working:

EndpointMethodAuthPurpose
/auth/mfa/statusGETProtectedCheck MFA enrollment status
/auth/mfa/setup-dataPOSTProtectedInitiate Email MFA setup (sends code)
/auth/mfa/verify-setupPOSTProtectedConfirm setup with email code
/auth/mfa/devicesGETProtectedList enrolled MFA devices
/auth/mfa/devices/:id/preferredPOSTProtectedSet preferred MFA device
/auth/mfa/devices/:idDELETEProtectedRemove an MFA device
/auth/respond-challengePOSTPublicComplete MFA challenge during login
/auth/challenge/setup-dataPOSTPublicGet setup data during forced MFA setup
/auth/challenge/resendPOSTPublicResend MFA verification code

Email MFA is the simplest method — it uses your existing email provider and requires no additional user setup (the email is already on file).

Sample apps

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

Prerequisites

  • Basic Auth Flows are working (signup, login, challenge endpoints)
  • An email provider is configured (use ConsoleEmailProvider for development)

Step 1: Install

npm install @nauth-toolkit/mfa-email

Step 2: Configure

config/auth.config.ts
import { MFAMethod } from '@nauth-toolkit/core';

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

rememberDevices: 'user_opt_in',
rememberDeviceDays: 30,
bypassMFAForTrustedDevices: true,
},

See How MFA Works > Configuration for the full config reference including enforcement modes, device trust, and grace periods.

Step 3: Add Backend Routes

MFA management routes (protected — user must be logged in)

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';

@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();
}

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

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

@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);
}
}

Register the controller in your auth module.

Challenge helper routes (public — used during login)

Add these to your existing auth controller/routes from the Basic Auth Flows guide:

src/auth/auth.controller.ts
// Add to your existing AuthController:

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

Step 4: Frontend — MFA Setup (Security Settings)

When a logged-in user enables MFA from their security settings page:

Check MFA status

GET /auth/mfa/status

Response:

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

Initiate setup

POST /auth/mfa/setup-data

Request body (SetupMFADTO):

{
"methodName": "email"
}

Response — a 6-digit code is sent to the user's email:

{
"setupData": {
"maskedEmail": "j***@example.com"
}
}

Show an input field for the user to enter the code they received.

Verify setup

POST /auth/mfa/verify-setup

Request body:

{
"methodName": "email",
"setupData": {
"code": "123456"
}
}

Response:

{
"deviceId": 42
}

MFA is now enabled. The next time this user logs in, they will receive an MFA_REQUIRED challenge.

Frontend setup example

React — MFA setup page (simplified)
const handleSetupEmail = async () => {
// Step 1: Initiate setup — sends code to user's email
const { setupData } = await fetch('/auth/mfa/setup-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ methodName: 'email' }),
}).then(r => r.json());

setMaskedEmail(setupData.maskedEmail);
setShowCodeInput(true);
};

const handleVerify = async (code: string) => {
// Step 2: Verify with code from email
const { deviceId } = await fetch('/auth/mfa/verify-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ methodName: 'email', setupData: { code } }),
}).then(r => r.json());

// MFA is enabled — show success
};

Step 5: Frontend — MFA Challenge (Login)

When a user with Email MFA logs in, the login response contains an MFA_REQUIRED challenge instead of tokens. A code is sent to their email automatically.

Login response (MFA enabled user)

POST /auth/login
{
"challengeName": "MFA_REQUIRED",
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"challengeParameters": {
"availableMethods": ["email"],
"preferredMethod": "email",
"destination": "j***@example.com",
"deliveryMedium": "EMAIL"
}
}

Show a code input screen. The user checks their email and enters the 6-digit code.

Complete the challenge

POST /auth/respond-challenge

Request body (RespondChallengeDTO):

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "MFA_REQUIRED",
"method": "email",
"code": "123456"
}

Response — tokens are issued:

{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"accessTokenExpiresAt": 1700000000,
"refreshTokenExpiresAt": 1700600000,
"user": {
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"isEmailVerified": true
}
}

Resend the code

POST /auth/challenge/resend
{
"session": "a21b654c-2746-4168-acee-c175083a65cd"
}

Rate limited — same limits as email verification.

Frontend challenge example

React — MFA challenge page (simplified)
// After login returns MFA_REQUIRED:
const handleMfaChallenge = async (code: string) => {
const response = await fetch('/auth/respond-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: pendingChallenge.session,
type: 'MFA_REQUIRED',
method: 'email',
code,
}),
}).then(r => r.json());

if (response.accessToken) {
// Login complete — navigate to dashboard
}
};

Forced Setup (REQUIRED Enforcement)

When enforcement: 'REQUIRED', users who haven't set up MFA receive an MFA_SETUP_REQUIRED challenge after login.

Login response (no MFA set up)

{
"challengeName": "MFA_SETUP_REQUIRED",
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"challengeParameters": {
"allowedMethods": ["email"]
}
}

Get setup data during login

POST /auth/challenge/setup-data

Request body (GetSetupDataDTO):

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"method": "email"
}

Possible responses:

ScenarioResponse
Email already verified{ "setupData": { "autoCompleted": true, "deviceId": 42 } }
Email not yet verified{ "setupData": { "maskedEmail": "j***@example.com" } } — code sent
Auto-completion

If the user already verified their email during signup, the setup auto-completes — no additional code is needed. The frontend can proceed directly to the respond-challenge step with the deviceId.

Complete the challenge

If auto-completed:

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "MFA_SETUP_REQUIRED",
"method": "email",
"setupData": { "deviceId": 42 }
}

If code verification needed:

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "MFA_SETUP_REQUIRED",
"method": "email",
"setupData": { "code": "123456" }
}

Both are sent to POST /auth/respond-challenge. On success, tokens are issued.

What's Next

  • SMS MFA — SMS verification codes (requires SMS provider)
  • TOTP MFA — Authenticator apps (Google Authenticator, Authy, 1Password)
  • Passkey MFA — Biometric authentication (Face ID, Touch ID, YubiKey)
  • How MFA Works — Adaptive MFA, error codes, device trust, enforcement modes