Skip to main content

TOTP MFA

Add time-based one-time password (TOTP) authentication. By the end of this guide, users will scan a QR code with their authenticator app and enter 6-digit codes during login. TOTP is the most popular MFA method — it works offline, has no delivery costs, and is supported by all major authenticator apps.

Supported apps: Google Authenticator, Microsoft Authenticator, Authy, 1Password, Duo Mobile, Bitwarden

EndpointMethodAuthPurpose
/auth/mfa/statusGETProtectedCheck MFA enrollment status
/auth/mfa/setup-dataPOSTProtectedGenerate QR code and secret
/auth/mfa/verify-setupPOSTProtectedConfirm setup with test code from authenticator
/auth/mfa/devicesGETProtectedList enrolled MFA devices
/auth/mfa/devices/:id/preferredPOSTProtectedSet preferred MFA device
/auth/mfa/devices/:idDELETEProtectedRemove an MFA device
/auth/respond-challengePOSTPublicComplete TOTP challenge during login
/auth/challenge/setup-dataPOSTPublicGet setup data during forced MFA setup
Sample apps

TOTP 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.

Prerequisites

  • Basic Auth Flows are working (signup, login, challenge endpoints)
  • TOTP has no external dependency — it uses a shared secret and time-based algorithm

Step 1: Install

npm install @nauth-toolkit/mfa-totp

Step 2: Configure

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

mfa: {
enabled: true,
enforcement: 'OPTIONAL',
allowedMethods: [MFAMethod.TOTP],
issuer: 'YourAppName', // Shown in authenticator apps

totp: {
window: 1, // Allow ±1 time step (30s tolerance)
stepSeconds: 30, // Code changes every 30 seconds
digits: 6, // 6-digit codes
algorithm: 'sha1', // 'sha1' | 'sha256' | 'sha512'
},

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

The issuer appears in the authenticator app as the account label (e.g., "YourAppName: user@example.com").

Step 3: Add Backend Routes

Already have MFA routes?

If you already set up routes for another MFA method (Email, SMS, Passkey), the same routes handle TOTP — just add MFAMethod.TOTP to allowedMethods in your config and skip to Step 4.

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 route (public — used during login)

Add 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)

TOTP setup is different from email/SMS — instead of receiving a code, the user scans a QR code and enters a test code to confirm.

Generate QR code

POST /auth/mfa/setup-data

Request body:

{
"methodName": "totp"
}

Response — includes a QR code (base64 PNG) and a manual entry key:

{
"setupData": {
"secret": "JBSWY3DPEHPK3PXP",
"qrCode": "data:image/png;base64,iVBORw0KGgo...",
"manualEntryKey": "JBSW Y3DP EHPK 3PXP",
"issuer": "YourAppName",
"accountName": "user@example.com"
}
}

Display the QR code for the user to scan with their authenticator app. Show manualEntryKey as a fallback for users who can't scan.

QR code format

The QR code encodes a standard otpauth:// URI:

otpauth://totp/YourAppName:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=YourAppName&algorithm=SHA1&digits=6&period=30

Verify with test code

After scanning, the user enters the current 6-digit code from their authenticator app.

POST /auth/mfa/verify-setup

Request body:

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

Response:

{
"deviceId": 44
}

TOTP MFA is now enabled. The next login triggers an MFA_REQUIRED challenge.

Frontend setup example

React — TOTP setup page (simplified)
const [setupData, setSetupData] = useState(null);
const [code, setCode] = useState('');

const handleSetupTotp = async () => {
// Step 1: Get QR code
const { setupData } = await fetch('/auth/mfa/setup-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ methodName: 'totp' }),
}).then(r => r.json());

setSetupData(setupData);
};

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

// TOTP MFA is enabled
};

// Render:
{setupData && (
<>
<h2>Scan this QR code with your authenticator app</h2>
<img src={setupData.qrCode} alt="TOTP QR Code" />
<p>Or enter this key manually: <code>{setupData.manualEntryKey}</code></p>
<input
type="text"
inputMode="numeric"
placeholder="Enter 6-digit code"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
<button onClick={handleVerify} disabled={code.length < 6}>Verify & Enable</button>
</>
)}

Step 5: Frontend — MFA Challenge (Login)

When a user with TOTP MFA logs in, no code is sent — the user generates it from their authenticator app.

Login response (MFA enabled user)

POST /auth/login
{
"challengeName": "MFA_REQUIRED",
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"challengeParameters": {
"availableMethods": ["totp"],
"preferredMethod": "totp"
}
}

Note: No destination or deliveryMedium — TOTP codes are generated locally. Hide the "Resend code" button in your UI when the method is totp.

Complete the challenge

POST /auth/respond-challenge

Request body:

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

Response — tokens are issued (same structure as Email MFA).

Frontend challenge example

React — TOTP challenge page (simplified)
const mfaMethod = challenge.challengeParameters.preferredMethod;
const isTotp = mfaMethod === 'totp';

// Heading:
isTotp
? 'Enter the 6-digit code from your authenticator app.'
: `We sent a 6-digit code to ${challenge.challengeParameters.destination}.`;

// Hide resend button for TOTP:
{!isTotp && (
<button onClick={handleResend}>Resend code</button>
)}

const handleSubmit = async (code: string) => {
const response = await fetch('/auth/respond-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session: challenge.session,
type: 'MFA_REQUIRED',
method: mfaMethod,
code,
}),
}).then(r => r.json());

if (response.accessToken) {
// Login complete
}
};

Troubleshooting

Codes not working

Clock skew: TOTP codes are time-based. If the user's device clock is out of sync, codes will be rejected. Ensure devices use automatic time synchronization. The window: 1 setting tolerates ±30 seconds of drift.

Wrong secret: If the user scanned the QR code twice or has an old entry, codes will fail. Remove the old entry in the authenticator app and re-scan a freshly generated QR code.

What's Next

  • Passkey MFA — Biometric authentication (Face ID, Touch ID, YubiKey)
  • Email MFA — Email verification codes (simplest to set up)
  • SMS MFA — SMS verification codes
  • How MFA Works — Adaptive MFA, error codes, device trust, enforcement modes