Skip to main content

Auth Challenge System

The nauth-toolkit uses a challenge-based architecture. Instead of failing with an error when verification is required (e.g., "Email not verified"), the system returns a Challenge Response.

This allows for complex, multi-step authentication flows (like MFA, email verification, or password resets) to be handled uniformly.

How It Works​

  1. User Action: A user attempts to Signup, Login, or use Social Auth.
  2. System Check: The system checks if any verification is required (e.g., Email Verification, MFA).
  3. Challenge Issued: If verification is needed, the system returns a Challenge object instead of Auth Tokens.
  4. User Response: The frontend prompts the user (e.g., "Enter SMS code") and sends the code back.
  5. Completion: If the code is correct, the system returns Auth Tokens (or the next Challenge if multiple steps are required).

The Unified API​

You don't need separate endpoints for every verification type. The toolkit provides a single, unified method to handle all challenges.

Backend Implementation​

import { Controller, Post, Body } from '@nestjs/common';
import { AuthService, Public } from '@nauth-toolkit/nestjs';
import { AuthErrorCode, NAuthException } from '@nauth-toolkit/core';

@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Public()
@Post('login')
async login(@Body() loginDto: LoginDTO) {
const result = await this.authService.login(loginDto);

// Check if challenge is required
if (result.challengeName) {
return {
challengeName: result.challengeName,
session: result.session,
challengeParameters: result.challengeParameters,
};
}

// Return tokens if no challenge
return result;
}

@Public()
@Post('challenge')
async respondToChallenge(@Body() dto: RespondToChallengeDTO) {
// Single endpoint for ALL challenge types
return this.authService.respondToChallenge({
session: dto.session,
type: dto.challengeType,
code: dto.code,
});
}
}

Frontend Implementation​

Your frontend needs to handle two states: Authenticated (Tokens) and Challenged (Verification Needed).

async function handleAuthResponse(response) {
// Case 1: Challenge Received
if (response.challengeName) {
switch (response.challengeName) {
case 'VERIFY_EMAIL':
showEmailVerificationScreen(response.session);
break;
case 'VERIFY_PHONE':
showSmsInputScreen(response.session);
break;
case 'MFA_REQUIRED':
showMfaInputScreen(response.session);
break;
}
return;
}

// Case 2: Success (Tokens Received)
if (response.accessToken) {
// Tokens are automatically stored in cookies if using cookie mode
// Otherwise, store them in localStorage/sessionStorage
// Use your router/navigation here
}
}

// Example: Login flow
async function login(email: string, password: string) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important for cookies
body: JSON.stringify({ email, password }),
});

const data = await response.json();
await handleAuthResponse(data);
}

// Example: Responding to a challenge
async function submitVerificationCode(session: string, code: string, challengeType: string) {
const response = await fetch('/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ session, code, challengeType }),
});

const data = await response.json();
await handleAuthResponse(data); // May return another challenge or tokens
}

Common Challenge Types​

1. VERIFY_EMAIL​

Issued when a user signs up with an email address and verificationMethod is set to email or both.

User Action: Enter the OTP sent to their email.

Next Step: Auth Tokens or VERIFY_PHONE (if both email and phone verification are required).

Example Response:

{
"challengeName": "VERIFY_EMAIL",
"session": "challenge-session-uuid",
"challengeParameters": {
"email": "user@example.com",
"codeDeliveryDestination": "u***@example.com"
}
}

2. VERIFY_PHONE​

Issued when:

  • User signs up with a phone number
  • User logs in via Social Auth but your app requires phone verification
  • Both email and phone verification are required (after email is verified)

User Action: Enter the SMS code sent to their phone. If the account has no phone yet, challengeParameters.requiresPhoneCollection is "true" and the user first submits a phone; the same challenge is returned with the code sent. Submitting phone again with a different number replaces it and resends.

Example Response:

{
"challengeName": "VERIFY_PHONE",
"session": "challenge-session-uuid",
"challengeParameters": {
"phone": "+1234567890",
"codeDeliveryDestination": "+***7890"
}
}

3. MFA_REQUIRED​

Issued during login if the user has 2FA enabled.

User Action: Enter TOTP code from authenticator app, SMS code, or use passkey.

Example Response:

{
"challengeName": "MFA_REQUIRED",
"session": "challenge-session-uuid",
"challengeParameters": {
"mfaMethods": ["TOTP", "SMS"]
}
}

4. MFA_SETUP_REQUIRED​

Issued during login when MFA enforcement is enabled and the user is required to set up MFA before being allowed to sign in.

When you’ll see it:

  • mfa.enabled = true
  • mfa.enforcement = 'REQUIRED' or 'ADAPTIVE' (and the grace period has expired or is disabled)

User Action: Take the user through MFA setup (e.g., TOTP QR code, SMS enrollment, passkey registration).

Example Response:

{
"challengeName": "MFA_SETUP_REQUIRED",
"session": "challenge-session-uuid",
"challengeParameters": {
"allowedMethods": ["sms", "totp"],
"requiresPhoneCollection": "true",
"instructions": "Multi-factor authentication setup is required before you can login"
}
}

requiresPhoneCollection appears only when SMS is an allowed method and the account has no phone number. The frontend then collects a number and passes it as setupData.phoneNumber to challenge/setup-data; the first SMS code verifies it. See SMS MFA > Forced Setup.

Next Step: Complete MFA setup, then call the challenge completion endpoint to finish the login.

Before it appears: while setup is still pending but not yet enforced, responses carry mfaGracePeriod instead of this challenge — including the signup response, so a client can run an optional setup flow early. Setting mfa.grace.skipForSignup also defers this challenge past signup so onboarding is never blocked by it.

Related:

5. FORCE_CHANGE_PASSWORD​

Issued during login when an admin has flagged the user's password for mandatory change.

User Action: Enter a new password.

Example Response:

{
"challengeName": "FORCE_CHANGE_PASSWORD",
"session": "challenge-session-uuid",
"challengeParameters": {}
}

6. Email + Phone verification (chained challenges)​

If your configuration requires both verifications (signup.verificationMethod = 'both'), challenges are returned sequentially:

  1. VERIFY_EMAIL
  2. then VERIFY_PHONE

Progressive Challenge Flow​

Challenges can be chained. Here's an example of a multi-step flow:

1. User signs up with email and phone
2. Response: { challengeName: 'VERIFY_EMAIL', session: '...' }
3. User verifies email
4. Response: { challengeName: 'VERIFY_PHONE', session: '...' }
5. User verifies phone
6. Response: { accessToken: '...', refreshToken: '...' }

Challenge Session Details​

  • Expiration: Challenge sessions expire after 15 minutes
  • Max Attempts: 3 attempts per session
  • Session Reuse: Each challenge response may return a new session token
  • Security: Sessions are single-use and tied to the specific user and challenge type

Error Handling​

@Public()
@Post('challenge')
async respondToChallenge(@Body() dto: RespondToChallengeDTO) {
try {
return await this.authService.respondToChallenge({
session: dto.session,
type: dto.challengeType,
code: dto.code,
});
} catch (error) {
if (error instanceof NAuthException) {
// Handle specific error codes
if (error.code === AuthErrorCode.VERIFICATION_CODE_INVALID) {
throw new BadRequestException('Invalid verification code');
}
if (error.code === AuthErrorCode.VERIFICATION_CODE_EXPIRED) {
throw new BadRequestException('Code expired. Please request a new one');
}
}
throw error;
}
}

Why This Approach?​

  1. Security: No half-authenticated accounts. Users are only fully authenticated when they complete all required verifications.
  2. Flexibility: Add new verification steps (Terms Acceptance, Identity Proofing, etc.) without changing the core authentication flow.
  3. User Experience: Handles complex flows like "Social Login → Phone Verification → MFA" seamlessly with a consistent API.
  4. Unified Interface: Single respondToChallenge method handles all challenge types, reducing code duplication.
  5. Progressive Disclosure: Users only see verification steps that are actually required for their specific situation.

What's Next​

  • Basic Auth Guide --- Implement login and signup with challenge handling
  • MFA Guide --- Multi-factor authentication setup and challenge flows
  • Error Handling --- How to handle challenge errors
  • Configuration --- Configure verification methods and MFA enforcement