Skip to main content

Passkey MFA

Add passkey-based multi-factor authentication using WebAuthn. By the end of this guide, users will authenticate with biometrics (Face ID, Touch ID, Windows Hello) or hardware security keys (YubiKey). Passkeys are the most secure MFA method — phishing-resistant, public key cryptography, no shared secrets.

EndpointMethodAuthPurpose
/auth/mfa/statusGETProtectedCheck MFA enrollment status
/auth/mfa/setup-dataPOSTProtectedGenerate WebAuthn registration options
/auth/mfa/verify-setupPOSTProtectedComplete registration with credential
/auth/mfa/devicesGETProtectedList enrolled MFA devices
/auth/mfa/devices/:id/preferredPOSTProtectedSet preferred MFA device
/auth/mfa/devices/:idDELETEProtectedRemove an MFA device
/auth/respond-challengePOSTPublicComplete passkey challenge during login
/auth/challenge/challenge-dataPOSTPublicGet WebAuthn assertion options during login
/auth/challenge/setup-dataPOSTPublicGet setup data during forced MFA setup
Sample apps

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

Prerequisites

  • Basic Auth Flows are working (signup, login, challenge endpoints)
  • HTTPS in production (WebAuthn requires a secure context)
  • A configured Relying Party (RP) domain

Step 1: Install

npm install @nauth-toolkit/mfa-passkey

Step 2: Configure

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

mfa: {
enabled: true,
enforcement: 'OPTIONAL',
allowedMethods: [MFAMethod.PASSKEY],

passkey: {
rpName: 'Your App Name', // Shown in browser prompt
rpId: 'yourdomain.com', // Must match the origin domain
origin: ['https://yourdomain.com'], // Allowed origins
timeout: 60000, // 60 seconds for user interaction
userVerification: 'preferred', // 'required' | 'preferred' | 'discouraged'
authenticatorAttachment: 'platform', // 'platform' (built-in) | 'cross-platform' (external keys)
},

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

Key settings:

SettingOptionsDescription
rpIdDomain stringMust match your site's domain. Use localhost for development.
originURL arrayAllowed origins including protocol. Must match rpId.
userVerificationrequired / preferred / discouragedWhether biometric/PIN is required. preferred lets the device decide.
authenticatorAttachmentplatform / cross-platformplatform = built-in (Face ID, Touch ID). cross-platform = external (YubiKey, phone via QR).

For local development:

config/auth.config.ts
passkey: {
rpName: 'NAuth Dev',
rpId: 'localhost',
origin: ['http://localhost:5173', 'http://localhost:4200'],
timeout: 60000,
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},

Step 3: Add Backend Routes

Already have MFA routes?

If you already set up routes for another MFA method (Email, SMS, TOTP), the same routes handle Passkey — just add MFAMethod.PASSKEY to allowedMethods in your config and add the challenge/challenge-data route shown in the challenge helper routes section below.

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 to your existing auth controller/routes from the Basic Auth Flows guide. Passkey needs two challenge helper routes:

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

// Passkey-specific: get WebAuthn assertion options during login
@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);
}

Step 4: Frontend — Passkey Registration (Security Settings)

Passkey setup uses the Web Authentication API. The backend generates registration options, the browser creates a credential, and the backend verifies it.

Get registration options

POST /auth/mfa/setup-data

Request body:

{
"methodName": "passkey"
}

Response — WebAuthn PublicKeyCredentialCreationOptions:

{
"setupData": {
"challenge": "dGhpcyBpcyBhIGNoYWxsZW5nZQ...",
"rp": { "name": "Your App Name", "id": "yourdomain.com" },
"user": {
"id": "dXNlci1zdWItaWQ...",
"name": "user@example.com",
"displayName": "John Doe"
},
"pubKeyCredParams": [
{ "alg": -7, "type": "public-key" },
{ "alg": -257, "type": "public-key" }
],
"timeout": 60000,
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"userVerification": "preferred"
},
"attestation": "none"
}
}

Create credential in browser

Call the WebAuthn browser API with the options from the backend:

const credential = await navigator.credentials.create({
publicKey: setupData,
});

The browser shows a native prompt (Face ID, Touch ID, Windows Hello, or security key).

Verify registration

POST /auth/mfa/verify-setup

Request body — send the full credential object:

{
"methodName": "passkey",
"setupData": {
"id": "credential-id-base64url",
"rawId": "raw-id-base64url",
"type": "public-key",
"response": {
"clientDataJSON": "base64url-encoded",
"attestationObject": "base64url-encoded"
}
}
}

Response:

{
"deviceId": 45
}

Users can enroll multiple passkeys (e.g., laptop Touch ID + phone Face ID + YubiKey).

Frontend registration example

React — Passkey setup (simplified)
const handleSetupPasskey = async () => {
// Step 1: Get registration options from backend
const { setupData: options } = await fetch('/auth/mfa/setup-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ methodName: 'passkey' }),
}).then(r => r.json());

// Step 2: Create credential (shows biometric prompt)
const credential = await navigator.credentials.create({ publicKey: options });

// Step 3: Send credential to backend for verification
const { deviceId } = await fetch('/auth/mfa/verify-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
methodName: 'passkey',
setupData: credential,
}),
}).then(r => r.json());

// Passkey registered
};

Step 5: Frontend — Passkey Challenge (Login)

Passkey login works differently from code-based methods. The frontend needs to get WebAuthn assertion options before the user can authenticate.

Login response (MFA enabled user)

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

Get challenge data

When the preferred method is passkey, call challenge-data to get WebAuthn assertion options:

POST /auth/challenge/challenge-data

Request body (GetChallengeDataDTO):

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

Response — WebAuthn assertion options:

{
"challengeData": {
"challenge": "cmFuZG9tLWNoYWxsZW5nZQ...",
"timeout": 60000,
"rpId": "yourdomain.com",
"allowCredentials": [
{
"id": "credential-id-base64url",
"type": "public-key",
"transports": ["internal"]
}
],
"userVerification": "preferred"
}
}

Authenticate with browser

const assertion = await navigator.credentials.get({
publicKey: challengeData,
});

The browser shows the biometric/security key prompt.

Complete the challenge

POST /auth/respond-challenge

Request body — send the assertion result as the credential field:

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "MFA_REQUIRED",
"method": "passkey",
"credential": {
"id": "credential-id-base64url",
"rawId": "raw-id-base64url",
"type": "public-key",
"response": {
"clientDataJSON": "base64url-encoded",
"authenticatorData": "base64url-encoded",
"signature": "base64url-encoded"
}
}
}

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

Frontend challenge example

React — Passkey challenge (simplified)
const handlePasskeyChallenge = async (session: string) => {
// Step 1: Get assertion options
const { challengeData } = await fetch('/auth/challenge/challenge-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session, method: 'passkey' }),
}).then(r => r.json());

// Step 2: Authenticate (shows biometric prompt)
const assertion = await navigator.credentials.get({ publicKey: challengeData });

// Step 3: Complete challenge
const response = await fetch('/auth/respond-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session,
type: 'MFA_REQUIRED',
method: 'passkey',
credential: assertion,
}),
}).then(r => r.json());

if (response.accessToken) {
// Login complete
}
};
Cross-device authentication

Users can authenticate on a desktop using a passkey stored on their phone via QR code scanning. This is handled automatically by the browser's WebAuthn implementation — no additional backend configuration is needed.

Security Advantages

Passkeys provide the highest security level of all MFA methods:

  • Phishing-resistant — cryptographically bound to the RP domain; cannot be tricked by lookalike sites
  • No shared secrets — uses public key cryptography; the private key never leaves the device
  • Replay-proof — each authentication uses a unique challenge
  • Platform-integrated — biometric prompts are handled by the OS

What's Next

  • Email MFA — Email verification codes (simplest to set up)
  • TOTP MFA — Authenticator app codes (offline, no delivery cost)
  • How MFA Works — Adaptive MFA, error codes, device trust, enforcement modes