Skip to main content

MFA Setup

Guide to implementing multi-factor authentication (MFA) setup, verification, and management in the frontend SDK.

Supported MFA Methods

MethodDescriptionRequirements
totpTime-based One-Time Password (Google Authenticator, Authy)Authenticator app
smsSMS verification codeVerified phone number
emailEmail verification codeVerified email address
passkeyWebAuthn/FIDO2 biometric authenticationHTTPS + browser support
backupSingle-use recovery codesMFA already configured

See MFAMethod and MFADeviceMethod for type definitions.

MFA During Authentication

MFA Required Challenge

When MFA is enabled and user logs in:

import { getMFAMethod, getMaskedDestination } from '@nauth-toolkit/client';

const response = await client.login(email, password);

if (response.challengeName === 'MFA_REQUIRED') {
// Get preferred MFA method
const method = getMFAMethod(response);
// Returns: 'sms' | 'email' | 'totp' | 'backup' | 'passkey' | undefined

// Get masked destination (automatically uses preferredMethod)
const destination = getMaskedDestination(response);
// For SMS: "***-***-9393"
// For Email: "m***2@example.com"

// Available methods in challengeParameters
const methods = response.challengeParameters?.availableMethods;
// ['totp', 'sms', 'backup']

navigateToMfaPage(response, method, destination);
}

Note: For MFA_REQUIRED challenges, the backend provides preferredMethod, maskedPhone, and maskedEmail in challengeParameters. Use getMaskedDestination() to get the correct masked destination based on the preferred method.

See AuthResponse for challenge structure and AuthChallenge for challenge types.

Verifying MFA

async function verifyMfa(method: string, code: string): Promise<void> {
const response = await client.respondToChallenge({
session: challengeSession.session,
type: 'MFA_REQUIRED',
method,
code,
});

if (!response.challengeName) {
if (typeof window !== 'undefined') {
window.location.href = '/dashboard';
}
}
}

// For passkey authentication
async function verifyPasskey(): Promise<void> {
// Option 1: SimpleWebAuthn (recommended)
import { startAuthentication } from '@simplewebauthn/browser';

// Get challenge options
const options = await client.getChallengeData(session, 'passkey');

// Authenticate with passkey
const credential = await startAuthentication(options);

// Verify
await client.respondToChallenge({
session,
type: 'MFA_REQUIRED',
method: 'passkey',
credential,
});
}

MFA Setup During Authentication

When MFA setup is enforced during signup/login:

MFA Setup Required Challenge

if (response.challengeName === 'MFA_SETUP_REQUIRED') {
const availableMethods = response.challengeParameters?.availableMethods;
navigateToMfaSetupPage(response);
}

Setting Up TOTP

Complete Flow:

// 1. Get TOTP setup data
const setupData = await client.getSetupData(session, 'totp');
// Returns GetSetupDataResponse: {
// setupData: {
// secret: 'JBSWY3DPEHPK3PXP', // Base32-encoded secret (REQUIRED for verification)
// qrCode: 'data:image/png;base64,...', // QR code data URL
// manualEntryKey: 'JBSW Y3DP EHPK 3PXP', // Formatted secret for manual entry
// issuer: 'MyApp', // Issuer name from backend config
// accountName: 'user@example.com' // User's email/identifier
// }
// }

// 2. Display QR code to user
displayQrCode(setupData.setupData.qrCode);

// 3. Also show manual entry option (if QR scan fails)
showManualEntryOption(setupData.setupData.manualEntryKey);

// 4. User scans QR code with authenticator app (Google Authenticator, Authy, etc.)
// Authenticator app generates 6-digit codes that refresh every 30 seconds

// 5. User enters verification code from authenticator app
// IMPORTANT: SDK validation requires BOTH secret and code
const response = await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'totp',
setupData: {
secret: setupData.setupData.secret, // Must include secret from getSetupData()
code: '123456', // User-entered 6-digit code from authenticator app
},
});

// 6. Handle response (may contain another challenge or success)
if (response.challengeName) {
// Progressive challenge (e.g., email verification next)
navigateToChallenge(response.challengeName);
} else {
// Setup complete, user authenticated
navigateToSuccess();
}

Key Points:

  • Secret is Required: The secret from getSetupData() must be included in setupData when calling respondToChallenge(). The SDK validates this client-side.
  • Manual Entry: If QR code scanning fails, users can manually enter the manualEntryKey into their authenticator app.
  • Device Name: Optional deviceName can be provided (defaults to "Authenticator App").
  • Progressive Challenges: After TOTP setup, the response may contain another challenge (e.g., VERIFY_EMAIL) or complete authentication.

Setting Up SMS MFA

// 1. Get SMS setup data
const setupData = await client.getSetupData(session, 'sms');

// May auto-complete if phone already verified
if (setupData.autoCompleted) {
// Already set up, complete challenge
await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'sms',
setupData: { deviceId: setupData.deviceId },
});
} else {
// Need to verify code
// setupData: { maskedPhone: '+1***5678' }

// User receives SMS and enters code
await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'sms',
setupData: { code: '123456' },
});
}

Setting Up Passkey (WebAuthn)

Prerequisites: HTTPS (or localhost) and browser support required.

For best developer experience, use @simplewebauthn/browser (handles encoding, errors, retry logic):

npm install @simplewebauthn/browser
import { startRegistration } from '@simplewebauthn/browser';

// 1. Get passkey registration options from backend
const { setupData } = await client.getSetupData(session, 'passkey');
const optionsJSON = setupData.options;

// 2. Prompt user to create passkey (Touch ID, Face ID, Windows Hello, etc.)
try {
const credential = await startRegistration({ optionsJSON });

// 3. Complete setup
await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'passkey',
setupData: {
credential: {
credential,
deviceName: 'My Passkey',
},
expectedChallenge: optionsJSON.challenge,
},
});

console.log('Passkey registered successfully');
} catch (error) {
if (error.name === 'NotAllowedError') {
console.error('User cancelled or timeout');
} else if (error.name === 'NotSupportedError') {
console.error('Passkeys not supported in this browser');
} else if (error.name === 'InvalidStateError') {
console.error('Passkey already registered, clear and retry');
}
}

Alternative: Native WebAuthn API

Use browser's native API (no dependencies, but requires manual error handling):

// 1. Get registration options
const { setupData } = await client.getSetupData(session, 'passkey');
const optionsJSON = setupData.options;

// 2. Create credential
const credential = await navigator.credentials.create({
publicKey: optionsJSON as unknown as PublicKeyCredentialCreationOptions,
});

// 3. Complete setup
await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'passkey',
setupData: {
credential: {
credential: credential as unknown as Record<string, unknown>,
deviceName: 'My Passkey',
},
expectedChallenge: optionsJSON.challenge,
},
});

Note: Native API requires manual base64url encoding and error handling.

MFA Management (Authenticated User)

Check MFA Status

const status = await client.getMfaStatus();
// status: {
// enabled: true,
// required: true,
// methods: ['totp', 'passkey'],
// availableMethods: ['totp', 'sms', 'email', 'passkey', 'backup'],
// preferredMethod: 'totp',
// hasBackupCodes: true,
// mfaExempt: false
// }

Get MFA Devices

const devices = await client.getMfaDevices(); // Returns MFADevice[]
// devices: [
// { id: 1, method: 'totp', name: 'Authenticator', createdAt: '...' },
// { id: 2, method: 'sms', name: 'Phone', createdAt: '...' }
// ]

Add New MFA Device

// 1. Get setup data (QR code, secret, etc.)
const setupData = await client.setupMfaDevice('totp');

// 2. Display QR code to user and wait for them to scan it
// setupData.setupData.qrCode — QR code data URL
// setupData.setupData.manualEntryKey — for manual entry fallback

// 3. Verify and complete (secret comes from setupData response)
const result = await client.verifyMfaSetup(
'totp',
{
secret: setupData.setupData.secret, // From setupMfaDevice() response
code: '123456', // User-entered code from authenticator app
},
'My Authenticator', // Optional device name (third parameter)
);
// result: { deviceId: 3 }

Remove a Single MFA Device

await client.removeMfaDeviceById(3);

Remove Specific Device by ID

For granular control, remove individual devices by their unique ID:

// Get user's devices
const devices = await client.getMfaDevices();
// [{ id: 48, name: "Google Authenticator", type: "totp", isPreferred: true },
// { id: 52, name: "Microsoft Authenticator", type: "totp", isPreferred: false }]

// Remove specific device
await client.removeMfaDeviceById(52);

Set Preferred Device

// Get user's devices
const devices = await client.getMfaDevices();

// Set preferred device by ID
await client.setPreferredMfaDevice(devices[0].id);

Generate Backup Codes

const codes = await client.generateBackupCodes(); // Returns string[] (see BackupCodesResponse)
// codes: ['ABC123', 'DEF456', 'GHI789', ...]

// Show to user and instruct to save securely
displayBackupCodes(codes);

Complete MFA Setup UI

@Component({
selector: 'app-mfa-setup',
template: `
<h2>Set Up Two-Factor Authentication</h2>

<div class="method-selector">
@for (method of availableMethods; track method) {
<button [class.active]="selectedMethod === method" (click)="selectMethod(method)">
{{ getMethodLabel(method) }}
</button>
}
</div>

@switch (selectedMethod) {
@case ('totp') {
<div class="totp-setup">
@if (qrCode) {
<p>Scan this QR code with your authenticator app:</p>
<img [src]="qrCode" alt="QR Code" />
<p class="manual-key">Manual entry: {{ manualKey }}</p>
}
<form (ngSubmit)="verifyTotp()">
<input [(ngModel)]="code" name="code" placeholder="Enter 6-digit code" />
<button [disabled]="loading">Verify</button>
</form>
</div>
}
@case ('sms') {
<div class="sms-setup">
@if (autoCompleted) {
<p>Your phone number is already verified.</p>
<button (click)="completeAutoSetup()">Continue</button>
} @else {
<p>Enter the code sent to {{ maskedPhone }}</p>
<form (ngSubmit)="verifySms()">
<input [(ngModel)]="code" placeholder="Enter code" />
<button [disabled]="loading">Verify</button>
</form>
}
</div>
}
@case ('passkey') {
<div class="passkey-setup">
<p>Register a passkey using your device.</p>
<button (click)="setupPasskey()">Register Passkey</button>
</div>
}
}
`,
})
export class MfaSetupComponent implements OnInit {
availableMethods: string[] = [];
selectedMethod = '';
qrCode = '';
manualKey = '';
maskedPhone = '';
autoCompleted = false;
deviceId?: number;
code = '';
loading = false;

private session = '';

constructor(
private auth: AuthService,
private router: Router,
) {}

async ngOnInit() {
const challenge = this.auth.getCurrentChallenge();
if (challenge?.challengeName !== 'MFA_SETUP_REQUIRED') {
this.router.navigate(['/login']);
return;
}

this.session = challenge.session!;
this.availableMethods = (challenge.challengeParameters?.['availableMethods'] as string[]) ?? [];
this.selectedMethod = this.availableMethods[0] ?? 'totp';
await this.loadSetupData();
}

async selectMethod(method: string) {
this.selectedMethod = method;
this.code = '';
await this.loadSetupData();
}

private async loadSetupData() {
this.loading = true;
try {
const data = await this.auth.getClient().getSetupData(this.session, this.selectedMethod);

if (this.selectedMethod === 'totp') {
this.qrCode = data.qrCode;
this.manualKey = data.manualEntryKey;
} else if (this.selectedMethod === 'sms') {
if (data.autoCompleted) {
this.autoCompleted = true;
this.deviceId = data.deviceId;
} else {
this.maskedPhone = data.maskedPhone;
}
}
} finally {
this.loading = false;
}
}

verifyTotp() {
this.completeSetup({ code: this.code });
}

verifySms() {
this.completeSetup({ code: this.code });
}

completeAutoSetup() {
this.completeSetup({ deviceId: this.deviceId });
}

async setupPasskey(): Promise<void> {
this.loading = true;
try {
// Dynamic import for SimpleWebAuthn (recommended)
const { startRegistration } = await import('@simplewebauthn/browser');

// Get registration options from backend
const options = await this.auth.getClient().getSetupData(this.session, 'passkey');

// Prompt user to create passkey
const credential = await startRegistration(options);

// Complete setup
this.completeSetup({ credential });
} catch (error: any) {
this.loading = false;
if (error.name === 'NotAllowedError') {
alert('Passkey registration cancelled or timed out');
} else if (error.name === 'NotSupportedError') {
alert('Passkeys are not supported in this browser');
} else {
alert('Failed to register passkey: ' + error.message);
}
}
}

private completeSetup(setupData: Record<string, unknown>) {
this.loading = true;
this.auth
.respondToChallenge({
session: this.session,
type: 'MFA_SETUP_REQUIRED',
method: this.selectedMethod,
setupData,
})
.subscribe({
next: (response) => {
this.loading = false;
if (response.challengeName) {
this.router.navigate(['/challenge', response.challengeName.toLowerCase()]);
} else {
this.router.navigate(['/dashboard']);
}
},
error: (err) => {
this.loading = false;
alert(err.message);
},
});
}

getMethodLabel(method: string): string {
const labels: Record<string, string> = {
totp: 'Authenticator App',
sms: 'SMS',
email: 'Email',
passkey: 'Passkey',
};
return labels[method] ?? method;
}
}

Backup Codes

During Setup

Show backup codes after MFA setup:

// After setting up first MFA device
const codes = await client.generateBackupCodes(); // Returns string[] (see BackupCodesResponse)

// Display to user
showBackupCodesModal(codes);

Using Backup Code

await client.respondToChallenge({
session,
type: 'MFA_REQUIRED',
method: 'backup',
code: 'ABC123',
});

Passkey Browser Support

BrowserVersionPlatform Support
Chrome/Edge67+ Windows Hello, Touch ID, security keys
Safari13+ Touch ID, Face ID (iOS 14+)
Firefox60+ Windows Hello, security keys

Check support at runtime:

if (window.PublicKeyCredential) {
// Passkeys supported
} else {
// Show alternative MFA methods
}

Platform authenticators (built-in): Touch ID, Face ID, Windows Hello Cross-platform authenticators (removable): USB security keys, NFC

See Backend Passkey Configuration for server setup.

Error Handling

MFA Errors

Error CodeDescriptionAction
MFA_INVALID_CODEWrong code enteredRetry with correct code
MFA_CODE_EXPIREDVerification code expiredRequest new code
MFA_DEVICE_NOT_FOUNDDevice doesn't existRe-setup MFA device
MFA_BACKUP_CODES_EXHAUSTEDAll backup codes usedGenerate new codes

TOTP-Specific Errors

Error CodeDescriptionAction
VALIDATION_FAILEDSDK validation: Missing secret or codeEnsure both secret (from getSetupData) and code (user input) are included
VERIFICATION_CODE_INVALIDTOTP code is incorrect or expiredCheck device time sync, request new code
MFA_SETUP_INVALIDTOTP setup verification failedVerify device time is synchronized, retry setup

SDK Client-Side Validation:

The SDK validates TOTP setup requests before sending to the backend:

// This will throw NAuthClientError with VALIDATION_FAILED
await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'totp',
setupData: { code: '123456' }, // Missing secret!
});

// Correct - includes both secret and code
const setupData = await client.getSetupData(session, 'totp');
await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'totp',
setupData: {
secret: setupData.setupData.secret, // From getSetupData
code: '123456', // From user
},
});

WebAuthn Errors

Error NameCauseUser-Friendly Message
NotAllowedErrorUser cancelled or timeout (60s)"Passkey registration cancelled. Please try again."
NotSupportedErrorBrowser doesn't support passkeys"Your browser doesn't support passkeys. Use another MFA method."
InvalidStateErrorCredential already registered"This passkey is already registered. Try a different device."
SecurityErrorNot HTTPS or invalid origin"Passkeys require HTTPS. Check your connection."
AbortErrorOperation aborted"Passkey registration interrupted. Please try again."

TOTP Setup Best Practices

Manual Entry Key

If QR code scanning fails (camera issues, damaged screen, etc.), users can manually enter the secret:

  1. Display the manualEntryKey from getSetupData() response
  2. User enters this into their authenticator app manually
  3. Authenticator app generates codes normally

Example:

const setupData = await client.getSetupData(session, 'totp');

// Show QR code (primary method)
<img src={setupData.setupData.qrCode} alt="QR Code" />

// Show manual entry option (fallback)
<div>
<p>Can't scan? Enter this code manually:</p>
<code>{setupData.setupData.manualEntryKey}</code>
<button onClick={() => copyToClipboard(setupData.setupData.manualEntryKey)}>
Copy
</button>
</div>

Device Name

Optionally provide a user-friendly device name for identification:

// Device name is optional - defaults to "Authenticator App"
// Currently not supported in challenge flow, but available in authenticated MFA management

Progressive Challenges

After TOTP setup completes, the response may contain:

  • Another Challenge: If email/phone verification is still required
  • Success: If all challenges are complete, returns user and tokens

Example:

const response = await client.respondToChallenge({
session,
type: 'MFA_SETUP_REQUIRED',
method: 'totp',
setupData: { secret, code },
});

if (response.challengeName) {
// Handle next challenge (e.g., VERIFY_EMAIL, VERIFY_PHONE)
switch (response.challengeName) {
case 'VERIFY_EMAIL':
navigateToEmailVerification(response);
break;
case 'VERIFY_PHONE':
navigateToPhoneVerification(response);
break;
}
} else {
// All challenges complete - user authenticated
navigateToDashboard();
}

Trusted Devices

Trusted devices allow users to skip MFA verification on devices they've previously marked as trusted. This improves UX while maintaining security.

How Trusted Devices Work

Token Delivery Modes:

  • Cookies Mode (Web): Device token stored as nauth_device_id httpOnly cookie
  • JSON Mode (Mobile): Device token returned in response body, stored securely by app
  • Hybrid Mode: Uses cookies for web, JSON for mobile

Device Trust Flow:

  1. User logs in with MFA
  2. After successful MFA, user can trust the device
  3. Device token is created and stored
  4. On next login, MFA is skipped if device is trusted and valid

Configuration

Backend configuration (see Backend MFA Configuration):

mfa: {
rememberDevices: 'user_opt_in', // Options: 'never', 'always', 'user_opt_in'
rememberDeviceDays: 30, // How long trust lasts
bypassMFAForTrustedDevices: true // Allow trusted devices to skip MFA
}
ModeBehavior
neverTrusted device feature disabled
alwaysDevices automatically trusted after MFA
user_opt_inUser chooses whether to trust device (recommended)

Trust Current Device

After successful MFA verification, allow user to trust the device:

// After successful MFA verification
const response = await client.respondToChallenge({
type: 'MFA_REQUIRED',
session: challengeSession,
method: 'totp',
code: userCode,
});

if (!response.challengeName) {
// Authentication complete, offer to trust device
const shouldTrust = confirm('Trust this device for 30 days?');

if (shouldTrust) {
try {
const result = await client.trustDevice();
console.log('Device trusted:', result.deviceToken);
// Token is automatically stored by SDK
} catch (error) {
console.error('Failed to trust device:', error);
}
}
}

Check Device Trust Status

Check if the current device is already trusted:

// Check if current device is trusted
try {
const result = await client.isTrustedDevice();

if (result.trusted) {
console.log('This device is trusted');
// Don't show "trust device" prompt
} else {
console.log('This device is not trusted');
// Show "trust device" option after MFA
}
} catch (error) {
// User not authenticated or feature disabled
console.error('Cannot check trust status:', error);
}

Security Considerations

Device Token Security:

  • Cookies Mode: Token stored as httpOnly cookie (cannot be accessed by JavaScript)
  • JSON Mode: Store token in secure storage
    • iOS: Use Keychain Services
    • Android: Use EncryptedSharedPreferences
    • Web: Tokens handled by SDK, stored in localStorage (cookies mode recommended for web)

Best Practices:

  1. Always use user_opt_in mode to let users choose
  2. Set reasonable expiration (30 days is recommended)
  3. Allow users to view and revoke trusted devices
  4. Log device trust events for audit trail
  5. Use cookies mode for web apps (httpOnly protection)
  6. Use JSON mode for mobile apps with secure storage

When Device Trust Fails:

Device trust will fail (MFA required) if:

  • Device token is missing or invalid
  • Trust has expired (past rememberDeviceDays)
  • Device token was tampered with
  • User revoked the device trust
  • Backend configuration changed

Revoking Device Trust

Users can revoke device trust in several ways:

1. Logout and forget current device:

// Logout and untrust current device
await client.logout(true); // forgetDevice = true

2. Global logout and forget all devices:

// Revoke all sessions AND all trusted devices
await client.logoutAll(true); // forgetDevices = true

3. Global logout (keep devices trusted):

// Revoke all sessions but keep devices trusted
await client.logoutAll(); // forgetDevices defaults to false
Authentication Required

All logout methods (logout() and logoutAll()) require the user to be authenticated. These endpoints are protected and cannot be called publicly.