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

SMS when the account has no phone:

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

// requiresPhoneCollection() is true on MFA_SETUP_REQUIRED when SMS is allowed and no phone is on file
if (requiresPhoneCollection(challenge)) {
// 1. Send the number with the setup request. It is saved (unverified) and the code goes to it.
// Call again with a different phoneNumber to change it, or with no setupData to resend.
await client.getSetupData(challenge.session!, 'sms', { phoneNumber: '+14155551234' });

// 2. The code verifies the phone and enrols the SMS device
await client.respondToChallenge({
session: challenge.session!,
type: AuthChallenge.MFA_SETUP_REQUIRED,
method: 'sms',
setupData: { code: '123456' },
});
}

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. True for VERIFY_PHONE when the account has no phone, and for MFA_SETUP_REQUIRED when SMS is an allowed method and the account has no phone:

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

// MFA_SETUP_REQUIRED: pass the number with the setup request instead
if (requiresPhoneCollection(challenge)) {
await client.getSetupData(challenge.session!, 'sms', { phoneNumber: '+14155551234' });
}

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)