Skip to main content

Challenge Handling

Authentication challenges are multi-step flows that require additional user verification beyond username/password.

Challenge Flow

Challenge Types

The SDK supports five challenge types defined in the AuthChallenge enum. Each challenge requires specific user actions and provides different parameters.

ChallengeWhen ReturnedUser ActionKey Parameters
VERIFY_EMAILEmail not verifiedEnter email codecodeDeliveryDestination, instructions
VERIFY_PHONEPhone not verifiedEnter/provide phone, then codecodeDeliveryDestination, requiresPhoneCollection, instructions
MFA_REQUIREDMFA enabledEnter MFA code or use passkeypreferredMethod, availableMethods, maskedPhone, maskedEmail
MFA_SETUP_REQUIREDMFA setup enforcedConfigure MFA deviceallowedMethods, instructions
FORCE_CHANGE_PASSWORDPassword change requiredSet new passwordinstructions

MFA Methods

The SDK supports five MFA methods defined in the MFAMethod type. Each method requires different user input and verification steps.

MethodDescriptionInput Required
'sms'SMS code6-digit code from SMS
'email'Email code6-digit code from email
'totp'Authenticator app (TOTP)6-digit code from app (Google, Authy, etc.)
'passkey'WebAuthn/FIDO2 passkeyBiometric or hardware key
'backup'Backup recovery codesSingle-use backup code

Automatic Navigation

The SDK can automatically handles navigation after login(), signup(), respondToChallenge(), and social OAuth flows using default routes. However, these can be configured or omitted altogether for custom behaviour.

Pattern 1: Separate Route for each challenge type (Default)

Each challenge gets its own route appended after your challengeBase setting.

{
baseUrl: 'https://api.example.com/auth', // backend url (not to be confused with redirects on frontend)
tokenDelivery: 'cookies',
redirects: {
challengeBase: '/auth/challenge', // Base path
loginSuccess: '/dashboard',
signupSuccess: '/onboarding',
sessionExpired: '/login',
oauthError: '/login',
},
}

Routes:

  • /auth/challenge/verify-email
  • /auth/challenge/verify-phone
  • /auth/challenge/mfa-required (default for SMS, email, TOTP)
  • /auth/challenge/mfa-required/passkey (when passkey is preferred method)
  • /auth/challenge/mfa-selector (when multiple methods available)
  • /auth/challenge/mfa-setup-required
  • /auth/challenge/force-change-password

Pattern 2: Single Route with Query Param

All challenges go to one route:

{
redirects: {
challengeBase: '/auth/challenge',
useSingleChallengeRoute: true, // Enable query param mode
},
}

Routes:

  • /auth/challenge?challenge=VERIFY_EMAIL
  • /auth/challenge?challenge=MFA_REQUIRED

Implementation:

@Component({
selector: 'app-challenge',
template: `
@switch (challengeType) {
@case ('VERIFY_EMAIL') {
<app-verify-email />
}
@case ('VERIFY_PHONE') {
<app-verify-phone />
}
@case ('MFA_REQUIRED') {
<app-mfa />
}
@case ('MFA_SETUP_REQUIRED') {
<app-mfa-setup />
}
@case ('FORCE_CHANGE_PASSWORD') {
<app-change-password />
}
}
`,
})
export class ChallengeComponent implements OnInit {
challengeType: string | null = null;

constructor(private route: ActivatedRoute) {}

ngOnInit(): void {
this.route.queryParams.subscribe((params) => {
this.challengeType = params['challenge'];
});
}
}

Pattern 3: Custom Routes

Override specific challenge routes using challengeRoutes:

import { AuthChallenge } from '@nauth-toolkit/client';

{
redirects: {
challengeRoutes: {
[AuthChallenge.MFA_REQUIRED]: '/auth/two-factor',
[AuthChallenge.VERIFY_EMAIL]: '/verify',
},
},
}

Route Building Priority:

The SDK builds challenge URLs in this order:

  1. Custom challengeRoutes - Highest priority, overrides everything
  2. useSingleChallengeRoute - Query param mode (/auth/challenge?challenge=VERIFY_EMAIL)
  3. mfaRoutes - MFA-specific routes (only for MFA_REQUIRED challenge)
  4. Default separate routes - Kebab-case routes based on challenge type

Pattern 3.5: MFA-Specific Routes

For fine-grained control over MFA navigation, use mfaRoutes:

{
redirects: {
challengeBase: '/auth/challenge',
mfaRoutes: {
passkey: '/auth/passkey', // When passkey is preferred method
selector: '/auth/choose-method', // When multiple methods available
default: '/auth/verify-code', // For SMS, email, TOTP
},
},
}

When each route is used:

  • passkey: When preferredMethod is 'passkey'
  • selector: When multiple availableMethods exist and no preferredMethod is set
  • default: For other MFA methods (SMS, email, TOTP)

Note: mfaRoutes only applies to MFA_REQUIRED challenges. It's evaluated after challengeRoutes but before default route construction.

See NAuthClientConfig Redirects for complete configuration reference.

Pattern 4: Dialog-Based (No Navigation)

Handle challenges with dialogs instead of pages:

import { NAuthClient, AuthResponse } from '@nauth-toolkit/client';

const client = new NAuthClient({
baseUrl: 'https://api.example.com/auth',
tokenDelivery: 'cookies',
onAuthResponse: (response: AuthResponse) => {
if (response.challengeName) {
openChallengeDialog(response);
} else if (response.user) {
window.location.href = '/dashboard';
}
},
});

function openChallengeDialog(challenge: AuthResponse) {
const dialog = document.getElementById('challenge-dialog')!;
const challengeType = challenge.challengeName;
const params = challenge.challengeParameters || {};

if (challengeType === 'VERIFY_EMAIL') {
const destination = params.codeDeliveryDestination as string;
dialog.innerHTML = `
<h2>Verify Your Email</h2>
<p>Code sent to ${destination}</p>
<form id="verify-email-form">
<input id="challenge-code" type="text" placeholder="000000" required />
<button type="submit">Verify</button>
</form>
`;
const form = dialog.querySelector('#verify-email-form') as HTMLFormElement;
form.onsubmit = (e) => handleVerifyEmail(e, challenge.session!);
} else if (challengeType === 'MFA_REQUIRED') {
const method = (params.preferredMethod as string) || 'totp';
const maskedKey = method === 'sms' ? 'maskedPhone' : 'maskedEmail';
const destination = params[maskedKey] as string;

dialog.innerHTML = `
<h2>Two-Factor Authentication</h2>
<p>Code sent to ${destination}</p>
<form id="verify-mfa-form">
<input id="mfa-code" type="text" placeholder="000000" required />
<button type="submit">Verify</button>
</form>
`;
const form = dialog.querySelector('#verify-mfa-form') as HTMLFormElement;
form.onsubmit = (e) => handleVerifyMFA(e, challenge.session!, method);
}

dialog.style.display = 'block';
}

async function handleVerifyEmail(event: Event, session: string) {
event.preventDefault();
const code = (document.getElementById('challenge-code') as HTMLInputElement).value;

await client.respondToChallenge({
session,
type: 'VERIFY_EMAIL',
code,
});
// SDK will call onAuthResponse again with result
}

async function handleVerifyMFA(event: Event, session: string, method: string) {
event.preventDefault();
const code = (document.getElementById('mfa-code') as HTMLInputElement).value;

await client.respondToChallenge({
session,
type: 'MFA_REQUIRED',
method,
code,
});
// SDK will call onAuthResponse again with result
}

Social OAuth with Challenges

Social OAuth Configuration

Social OAuth flows use the same challenge configuration as regular login/signup. The redirects configuration you set up in Automatic Navigation applies to all authentication flows (login, signup, and social OAuth).

Key difference: Social OAuth requires a callback route handler to exchange the OAuth token. After token exchange, challenge handling works identically to regular flows.

Use the socialRedirectCallbackGuard to automatically handle the OAuth callback:

// app.routes.ts
import { socialRedirectCallbackGuard } from '@nauth-toolkit/client-angular';

export const routes: Routes = [
{
path: 'auth/callback',
canActivate: [socialRedirectCallbackGuard],
children: [], // Guard handles token exchange and navigation
},
// ... your challenge routes (same as regular login)
{
path: 'auth/challenge',
children: [
{ path: 'verify-email', component: VerifyEmailComponent },
{ path: 'mfa-required', component: MfaComponent },
],
},
];

// login.component.ts
async onGoogleLogin() {
await this.auth.loginWithSocial('google', {
returnTo: '/auth/callback',
});
// Guard automatically:
// 1. Exchanges OAuth token
// 2. Navigates to challenge (if needed) or success URL
}

The guard uses your existing redirects configuration from NAUTH_CLIENT_CONFIG. All navigation patterns (separate routes, single route, custom routes, dialog-based) work the same as regular login.

Important Notes

  1. Callback route required: Always create an /auth/callback route. In Angular, use socialRedirectCallbackGuard. In vanilla JS/TS, manually call exchangeSocialRedirect().
  2. Same configuration: The redirects config you set for regular login/signup applies to social OAuth challenges.
  3. No duplicate setup: You don't need separate challenge routes or configuration for social OAuth - it uses the same setup as regular authentication flows.

Implementing Challenges

Email Verification

@Component({
selector: 'app-verify-email',
template: `
<h2>Verify Your Email</h2>
<p>Code sent to {{ maskedEmail }}</p>

<form (ngSubmit)="verify()">
<input [(ngModel)]="code" placeholder="000000" />
<button [disabled]="loading">Verify</button>
</form>

<button (click)="resend()">Resend Code</button>
`,
})
export class VerifyEmailComponent implements OnInit {
code = '';
loading = false;
maskedEmail = '';
private session = '';

constructor(private auth: AuthService) {}

ngOnInit(): void {
const challenge = this.auth.getCurrentChallenge();
this.session = challenge?.session || '';
this.maskedEmail = challenge?.challengeParameters?.codeDeliveryDestination || '';
}

async verify(): Promise<void> {
this.loading = true;
try {
await this.auth.respondToChallenge({
session: this.session,
type: 'VERIFY_EMAIL',
code: this.code,
});
// SDK navigates automatically
} finally {
this.loading = false;
}
}

async resend(): Promise<void> {
await this.auth.resendCode(this.session);
}
}

MFA Verification

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

@if (availableMethods.length > 1) {
<div>
@for (method of availableMethods; track method) {
<button (click)="selectMethod(method)">{{ method }}</button>
}
</div>
}

@if (selectedMethod !== 'passkey') {
<form (ngSubmit)="verify()">
<input [(ngModel)]="code" placeholder="Enter code" />
<button [disabled]="loading">Verify</button>
</form>
} @else {
<button (click)="verifyPasskey()">Use Passkey</button>
}
`,
})
export class MfaComponent implements OnInit {
availableMethods: string[] = [];
selectedMethod = '';
code = '';
loading = false;
private session = '';

constructor(private auth: AuthService) {}

ngOnInit(): void {
const challenge = this.auth.getCurrentChallenge();
this.session = challenge?.session!;
this.availableMethods = challenge?.challengeParameters?.availableMethods || [];
this.selectedMethod = challenge?.challengeParameters?.preferredMethod || this.availableMethods[0];
}

async verify(): Promise<void> {
this.loading = true;
try {
await this.auth.respondToChallenge({
session: this.session,
type: 'MFA_REQUIRED',
method: this.selectedMethod,
code: this.code,
});
// SDK navigates automatically
} finally {
this.loading = false;
}
}

async verifyPasskey(): Promise<void> {
const response = await this.auth.getChallengeData(this.session, 'passkey');
const options = response.challengeData as PublicKeyCredentialRequestOptions;
const credential = await navigator.credentials.get({ publicKey: options });

await this.auth.respondToChallenge({
session: this.session,
type: 'MFA_REQUIRED',
method: 'passkey',
credential: credential as unknown as Record<string, unknown>,
});
// SDK navigates automatically
}
}

Phone Verification

Phone challenges may require phone collection first:

import { NAuthClient, requiresPhoneCollection, AuthChallenge } from '@nauth-toolkit/client';

const client = new NAuthClient({
baseUrl: 'https://api.example.com/auth',
tokenDelivery: 'cookies',
});

// Login returns AuthResponse - SDK automatically navigates unless onAuthResponse is provided
const challenge = await client.login(email, password);

if (challenge.challengeName === AuthChallenge.VERIFY_PHONE) {
if (requiresPhoneCollection(challenge)) {
// Step 1: Collect phone (returns new AuthResponse with updated session)
const phoneResponse = await client.respondToChallenge({
session: challenge.session!,
type: AuthChallenge.VERIFY_PHONE,
phone: '+14155551234', // E.164 format
});

// Step 2: Verify code using session from phone collection response
await client.respondToChallenge({
session: phoneResponse.session!,
type: AuthChallenge.VERIFY_PHONE,
code: '123456',
});
} else {
// Phone already collected, just verify code
await client.respondToChallenge({
session: challenge.session!,
type: AuthChallenge.VERIFY_PHONE,
code: '123456',
});
}
}

MFA Setup

import { NAuthClient, AuthChallenge } from '@nauth-toolkit/client';

const client = new NAuthClient({
baseUrl: 'https://api.example.com/auth',
tokenDelivery: 'cookies',
});

// 1. Get setup data
const response = await client.getSetupData(session, 'totp');
// Returns: { setupData: { secret, qrCode, manualEntryKey, issuer, accountName, ... } }

// 2. User scans QR code and enters code from authenticator app

// 3. Complete setup
await client.respondToChallenge({
session,
type: AuthChallenge.MFA_SETUP_REQUIRED,
method: 'totp',
setupData: {
secret: response.setupData.secret as string, // From step 1
code: '123456', // From user's authenticator
},
});

Force Password Change

import { NAuthClient, AuthChallenge } from '@nauth-toolkit/client';

const client = new NAuthClient({
baseUrl: 'https://api.example.com/auth',
tokenDelivery: 'cookies',
});

await client.respondToChallenge({
session: challenge.session!,
type: AuthChallenge.FORCE_CHANGE_PASSWORD,
newPassword: 'NewSecurePassword123!',
});

Challenge State

The SDK automatically persists challenge sessions to storage (survives page refresh):

// Get current challenge (from reactive state or storage)
const challenge = this.auth.getCurrentChallenge();
// Returns: AuthResponse | null

if (challenge?.challengeName) {
// Resume challenge flow
// Option 1: Use challenge router to navigate
const router = this.auth.getChallengeRouter();
await router.navigateToChallenge(challenge);

// Option 2: Manual navigation
const url = router.getChallengeUrl(challenge);
window.location.href = url;
}

// Clear when done (after successful challenge completion)
await this.auth.clearChallenge();

Note: In Angular, you can also subscribe to challenge$ observable for reactive updates:

this.auth.challenge$.subscribe((challenge) => {
if (challenge?.challengeName) {
// Handle challenge
}
});

Note: Challenge sessions are automatically stored when a challenge is received and cleared when authentication succeeds.

Helper Utilities

The SDK provides type-safe helper functions to reduce boilerplate when working with challenges:

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

getMaskedDestination()

Get masked email/phone from any challenge:

const challenge = await client.login(email, password);
const destination = getMaskedDestination(challenge);

// Returns:
// VERIFY_EMAIL: "u***r@example.com"
// VERIFY_PHONE: "***-***-1234"
// MFA_REQUIRED (SMS): "***-***-9393"
// MFA_REQUIRED (Email): "m***2@example.com"

// Usage in UI
console.log(`Code sent to ${destination}`);

getMFAMethod()

Get the preferred MFA method:

const method = getMFAMethod(challenge);
// Returns: 'sms' | 'email' | 'totp' | 'backup' | 'passkey' | undefined

if (method === 'passkey') {
// Show passkey UI
const options = await client.getChallengeData(challenge.session!, 'passkey');
const credential = await navigator.credentials.get({ publicKey: options });
} else {
// Show code input
}

requiresPhoneCollection()

Check if user needs to provide phone number:

if (requiresPhoneCollection(challenge)) {
// Step 1: Collect phone
await client.respondToChallenge({
session: challenge.session!,
type: 'VERIFY_PHONE',
phone: '+14155551234',
});
} else {
// Step 2: Verify code
await client.respondToChallenge({
session: challenge.session!,
type: 'VERIFY_PHONE',
code: '123456',
});
}

getChallengeInstructions()

Get user-friendly instructions:

const instructions = getChallengeInstructions(challenge);
// Returns string from backend or undefined

if (instructions) {
console.log(instructions);
// e.g., "Please verify your email to continue"
}

isOTPChallenge()

Check if challenge requires code input:

if (isOTPChallenge(challenge)) {
// Show 6-digit code input
// true for: VERIFY_EMAIL, VERIFY_PHONE, MFA_REQUIRED
} else {
// Other input (password, passkey, etc.)
}

See Challenge Helpers API for complete reference.

Error Handling

CodeDescriptionAction
VERIFY_CODE_INVALIDWrong verification codeShow error, allow retry
VERIFY_CODE_EXPIREDCode expiredResend code
CHALLENGE_EXPIREDChallenge session expiredRestart login
RATE_LIMIT_SMSToo many SMS sentShow retry timer
import { NAuthErrorCode } from '@nauth-toolkit/client';

try {
await client.respondToChallenge({...});
} catch (error) {
switch (error.code) {
case NAuthErrorCode.VERIFY_CODE_INVALID:
showError('Invalid code. Please try again.');
break;
case NAuthErrorCode.VERIFY_CODE_EXPIRED:
await client.resendCode(session);
showMessage('Code expired. New code sent.');
break;
case NAuthErrorCode.CHALLENGE_EXPIRED:
showError('Session expired. Please login again.');
navigateTo('/login');
break;
case NAuthErrorCode.RATE_LIMIT_SMS:
showError(`Too many attempts. Retry in ${error.details?.retryAfter}s`);
break;
}
}

Configuration Reference

OptionDescriptionDefaultExample
redirects.loginSuccessURL after successful login/social (no challenge)'/''/dashboard'
redirects.signupSuccessURL after successful signup (no challenge)undefined'/onboarding'
redirects.successLegacy alias for redirects.loginSuccessundefined'/dashboard'
redirects.sessionExpiredURL when session expires'/login''/login?expired=true'
redirects.oauthErrorURL when OAuth authentication fails'/login''/login?error=oauth'
redirects.challengeBaseBase path for challenges'/auth/challenge''/auth/challenge'
redirects.useSingleChallengeRouteUse query param instead of separate routesfalsetrue
redirects.challengeRoutesCustom route for each challengeundefined{ [AuthChallenge.MFA_REQUIRED]: '/mfa' }
redirects.mfaRoutesMFA-specific overrides (MFA_REQUIRED only)undefined{ passkey: '/auth/passkey', default: '/auth/verify-code' }
onAuthResponseCustom handler (disables auto-navigation)undefined(response, context) => dialog.open(...)
navigationHandlerCustom router integrationwindow.location.replace(url) => router.navigateByUrl(url)