Skip to main content

Basic Auth Flows

This guide walks through implementing core authentication: signup with email verification, login, challenge handling, token refresh, logout, and password reset.

By the end you will have a working auth layer with these endpoints:

EndpointMethodAuthPurpose
/auth/signupPOSTPublicRegister a new user
/auth/loginPOSTPublicAuthenticate with email + password
/auth/respond-challengePOSTPublicComplete email verification (or any challenge)
/auth/challenge/resendPOSTPublicResend verification code
/auth/refreshPOSTPublicGet new access token using refresh token
/auth/logoutGETProtectedEnd current session
/auth/forgot-passwordPOSTPublicRequest password reset code
/auth/forgot-password/confirmPOSTPublicReset password with code
/auth/change-passwordPOSTProtectedChange password (authenticated)
/auth/profileGETProtectedGet current user profile
Sample apps

The code in this guide is taken directly from the example apps. If you get stuck, clone and run them:

Prerequisites

You have completed the Quick Start for your framework and have:

  • nauth-toolkit installed and configured
  • A database connection (PostgreSQL or MySQL)
  • A storage adapter (database or Redis)
  • An email provider configured (use ConsoleEmailProvider for development)

The configuration used in this guide:

signup: {
enabled: true,
verificationMethod: 'email', // email verification ON
// phoneVerification is not configured — phone verification OFF
emailVerification: {
expiresIn: 3600, // 1 hour
resendDelay: 60, // 1 minute between resends
rateLimitMax: 3,
rateLimitWindow: 3600,
},
},

See Configuration for the full reference.

How the Challenge System Works

Before diving into routes, understand the pattern every auth endpoint follows. Instead of separate endpoints for each verification type, nauth-toolkit uses a single challenge/response loop:

Every auth operation (signup, login, social login) returns an AuthResponseDTO. Check challengeName:

  • Present — the user must complete a challenge (email verification, MFA, etc.). The session token identifies this pending flow.
  • Absent — authentication is complete. Tokens are delivered per your tokenDelivery config.

This means you only need one challenge-handling endpoint for email verification, phone verification, MFA, and forced password change. The type field in the request tells nauth-toolkit which challenge you're responding to.

Signup

Creates a new user account. With verificationMethod: 'email', the response always contains a VERIFY_EMAIL challenge instead of tokens.

src/auth/auth.controller.ts
import { Controller, Post, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService, SignupDTO, AuthResponseDTO, AuthGuard, Public } from '@nauth-toolkit/nestjs';

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

@Public()
@Post('signup')
@HttpCode(HttpStatus.CREATED)
async signup(@Body() dto: SignupDTO): Promise<AuthResponseDTO> {
return await this.authService.signup(dto);
}
}

@UseGuards(AuthGuard) at class level protects all routes by default. Routes marked @Public() skip JWT validation.

Request body (SignupDTO):

{
"email": "john@example.com",
"password": "SecurePass123!",
"firstName": "John",
"lastName": "Doe"
}

Response — with email verification enabled, you always get a challenge:

{
"challengeName": "VERIFY_EMAIL",
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"challengeParameters": {
"codeDeliveryDestination": "j***@example.com"
}
}

The session token is short-lived and scoped to this verification flow. Store it in your frontend state — you will need it for the next step.

Email masking

codeDeliveryDestination shows a masked email by default (j***@example.com). To return the full email (development only), set security.maskSensitiveData: false in your config.

Respond to Challenge

This is the single unified endpoint that handles all challenge types. The type field tells nauth-toolkit what you're responding to.

src/auth/auth.controller.ts
import { RespondChallengeDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class:

@Public()
@Post('respond-challenge')
@HttpCode(HttpStatus.OK)
async respondToChallenge(@Body() dto: RespondChallengeDTO): Promise<AuthResponseDTO> {
return await this.authService.respondToChallenge(dto);
}

Email verification

After signup, the user receives a 6-digit code via email. Send it back with the session token:

Request body (RespondChallengeDTO):

{
"session": "a21b654c-2746-4168-acee-c175083a65cd",
"type": "VERIFY_EMAIL",
"code": "123456"
}

Success response — verification complete, tokens issued:

{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"accessTokenExpiresAt": 1740000900,
"refreshTokenExpiresAt": 1740604800,
"authMethod": "password",
"trusted": false,
"user": {
"sub": "a21b654c-2746-4168-acee-c175083a65cd",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"isEmailVerified": true
}
}

If MFA is enabled and the user has MFA set up, the response may contain another challenge (MFA_REQUIRED) instead of tokens. See the MFA guide for handling that flow.

Other challenge types (same endpoint)

This same endpoint handles every challenge type. Here are the request shapes for reference:

Phone verification (when verificationMethod: 'phone' or 'both'):

{
"session": "...",
"type": "VERIFY_PHONE",
"code": "123456"
}

Phone number collection — if the user doesn't have a phone number yet (e.g., signed up with email only), you can collect it through the same challenge:

{
"session": "...",
"type": "VERIFY_PHONE",
"phone": "+1234567890"
}

After submitting the phone number, the backend sends an SMS and returns the same challenge for code verification.

MFA verification (covered in the MFA guide):

{
"session": "...",
"type": "MFA_REQUIRED",
"method": "totp",
"code": "123456"
}

Forced password change:

{
"session": "...",
"type": "FORCE_CHANGE_PASSWORD",
"newPassword": "NewSecurePass123!"
}

Resend Code

Users may not receive the verification email in time, or the code may expire. This endpoint resends the code for the current challenge session.

src/auth/auth.controller.ts
import { ResendCodeDTO, ResendCodeResponseDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class:

@Public()
@Post('challenge/resend')
@HttpCode(HttpStatus.OK)
async resendCode(@Body() dto: ResendCodeDTO): Promise<ResendCodeResponseDTO> {
return await this.authService.resendCode(dto);
}

Request body (ResendCodeDTO):

{
"session": "a21b654c-2746-4168-acee-c175083a65cd"
}

Response:

{
"destination": "j***@example.com"
}
Rate limiting

Resend is rate-limited by signup.emailVerification.resendDelay (default: 60 seconds) and rateLimitMax (default: 3 per window). If the user hits the limit, a 429 Too Many Requests error is returned. See Rate Limiting for details.

Login

Authenticates a user with identifier (email, username, or phone) and password. Returns tokens directly, or a challenge if email verification is pending or MFA is required.

src/auth/auth.controller.ts
import { LoginDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class:

@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDTO): Promise<AuthResponseDTO> {
return await this.authService.login(dto);
}

Request body (LoginDTO):

{
"identifier": "john@example.com",
"password": "SecurePass123!"
}

The identifier field accepts email, username, or phone depending on your login.identifierType config. When not configured, all types are accepted.

Possible responses:

ScenarioResponse
Email verified, no MFA{ accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt }
Email not verified{ challengeName: 'VERIFY_EMAIL', session, challengeParameters }
MFA required{ challengeName: 'MFA_REQUIRED', session, challengeParameters }
MFA setup required (enforcement: REQUIRED){ challengeName: 'MFA_SETUP_REQUIRED', session, challengeParameters }
Password expired{ challengeName: 'FORCE_CHANGE_PASSWORD', session }

All challenge responses are handled by the same /auth/respond-challenge endpoint documented above.

Account lockout

If lockout.enabled: true, failed login attempts from the same IP are tracked. After maxAttempts failures within attemptWindow, the IP is locked out for duration seconds. See Configuration > Account Lockout.

Refresh Token

Issues a new access token using a valid refresh token. The refresh token itself is rotated on every use (a new refresh token is returned). Old refresh tokens are invalidated.

src/auth/auth.controller.ts
import { Req } from '@nestjs/common';
import { RefreshTokenDTO, TokenResponse } from '@nauth-toolkit/nestjs';
import type { Request } from 'express';

// Inside AuthController class:

@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() dto: RefreshTokenDTO, @Req() req: Request): Promise<TokenResponse> {
return await this.authService.refreshToken({
refreshToken: dto?.refreshToken || req.cookies?.['nauth_refresh_token'],
});
}

Request body (RefreshTokenDTO) — for json token delivery:

{
"refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}

For cookies or hybrid mode, the refresh token is sent automatically via the nauth_refresh_token httpOnly cookie. The body can be empty — the route handler falls back to the cookie value.

Response:

{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"accessTokenExpiresAt": 1740000900,
"refreshTokenExpiresAt": 1740604800
}
Reuse detection

If jwt.refreshToken.reuseDetection: true (recommended), using an already-rotated refresh token invalidates the entire token family and forces re-authentication. This detects token theft — if an attacker replays an old refresh token, all sessions in that family are revoked.

Logout

Ends the current session and clears authentication cookies. Uses GET to avoid CSRF issues for session destruction.

src/auth/auth.controller.ts
import { Get, Query } from '@nestjs/common';
import { LogoutDTO, LogoutResponseDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class (no @Public — requires valid JWT):

@Get('logout')
@HttpCode(HttpStatus.OK)
async logout(@Query() dto: LogoutDTO): Promise<LogoutResponseDTO> {
return await this.authService.logout(dto);
}

Query parameters (LogoutDTO):

ParameterTypeDescription
forgetMebooleanOptional. If true, also untrusts the device (removes device token cookie).

Response:

{
"success": true
}

In cookies or hybrid mode, the response also clears the nauth_access_token, nauth_refresh_token, and nauth_csrf_token cookies automatically.

Why GET?

Logout uses GET instead of POST so that:

  • No request body is needed
  • No CSRF token is required (GET requests are exempt)
  • The frontend can trigger logout with a simple redirect or fetch()

The sub (user ID) is extracted automatically from the JWT in the request. You don't need to pass it in the body.

Forgot Password

Two-step password reset flow: request a code, then confirm with the code and new password.

Step 1: Request reset code

src/auth/auth.controller.ts
import { ForgotPasswordDTO, ForgotPasswordResponseDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class:

@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() dto: ForgotPasswordDTO): Promise<ForgotPasswordResponseDTO> {
// baseUrl constructs a clickable link in the email: ${baseUrl}?code=${code}
dto.baseUrl = `${process.env.FRONTEND_BASE_URL || 'http://localhost:4200'}/auth/reset-password`;
return await this.authService.forgotPassword(dto);
}

Request body (ForgotPasswordDTO):

{
"identifier": "john@example.com"
}

Response:

{
"success": true,
"destination": "u***@example.com",
"deliveryMedium": "email",
"expiresIn": 900
}
Security

The response is always the same regardless of whether the account exists. This prevents user enumeration attacks. The baseUrl is optional — if provided, the email includes a clickable reset link; otherwise, only the 6-digit code is sent.

Step 2: Confirm reset

src/auth/auth.controller.ts
import { ConfirmForgotPasswordDTO, ConfirmForgotPasswordResponseDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class:

@Public()
@Post('forgot-password/confirm')
@HttpCode(HttpStatus.OK)
async confirmForgotPassword(@Body() dto: ConfirmForgotPasswordDTO): Promise<ConfirmForgotPasswordResponseDTO> {
return await this.authService.confirmForgotPassword(dto);
}

Request body (ConfirmForgotPasswordDTO):

{
"identifier": "john@example.com",
"code": "123456",
"newPassword": "NewSecurePass123!"
}

The new password is validated against your password policy (min length, complexity, history, etc.).

Change Password

Allows an authenticated user to change their current password. Requires the old password for verification.

src/auth/auth.controller.ts
import { ChangePasswordDTO, ChangePasswordResponseDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class (no @Public — requires valid JWT):

@Post('change-password')
@HttpCode(HttpStatus.OK)
async changePassword(@Body() dto: ChangePasswordDTO): Promise<ChangePasswordResponseDTO> {
return await this.authService.changePassword(dto);
}

Request body (ChangePasswordDTO):

{
"oldPassword": "SecurePass123!",
"newPassword": "EvenMoreSecure456!"
}

If password.historyCount is configured (e.g., 5), the new password cannot match the last N passwords.

Get Profile

Returns the authenticated user's profile.

src/auth/auth.controller.ts
import { CurrentUser, IUser, UserResponseDTO } from '@nauth-toolkit/nestjs';

// Inside AuthController class (no @Public — requires valid JWT):

@Get('profile')
async getProfile(@CurrentUser() user: IUser): Promise<UserResponseDTO> {
return UserResponseDTO.fromEntity(user);
}

@CurrentUser() extracts the user from the JWT-validated request. UserResponseDTO.fromEntity() strips sensitive fields (password hash, etc.) before returning.

Error Handling

Set up a global error handler so NAuthException errors are returned as structured JSON.

src/main.ts
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { NAuthHttpExceptionFilter, NAuthValidationPipe } from '@nauth-toolkit/nestjs';

async function bootstrap(): Promise<void> {
// ExpressAdapter is required for cookie-based token delivery
const app = await NestFactory.create(AppModule, new ExpressAdapter());

// Required for reading nauth_refresh_token and nauth_access_token cookies
app.use(cookieParser());

// Translates NAuthException into HTTP responses with error codes
app.useGlobalFilters(new NAuthHttpExceptionFilter());

// Validates DTOs using class-validator decorators
app.useGlobalPipes(new NAuthValidationPipe());

await app.listen(3000);
}

Error responses follow this structure:

{
"code": "AUTH_INVALID_CREDENTIALS",
"message": "Invalid email or password",
"timestamp": "2025-01-15T10:30:00.000Z"
}

See Error Handling for the full list of error codes and how to handle them in your frontend.

Frontend Integration

Here's how the Angular example app handles the auth flow. See the Angular Quick Start for full setup details.

Handling signup + email verification

src/app/signup/signup.component.ts (simplified)
import { AuthService, AuthResponse } from '@nauth-toolkit/client-angular/standalone';
import { ChallengeOrchestratorService } from '../services/challenge-orchestrator.service';

// Inside SignupComponent:

async onSubmit(): Promise<void> {
const response: AuthResponse = await this.auth.signup({
email,
password,
firstName,
lastName,
});

// Orchestrator checks response.challengeName and navigates to the correct
// challenge route (verify-email, mfa-required, etc.) or to the success URL
await this.orchestrator.handleAuthResponse(response);
}

The ChallengeOrchestratorService is a service from the sample app that reads response.challengeName and routes to the appropriate component — the same pattern applies to any framework.

Handling login

src/app/login/login.component.ts (simplified)
// Inside LoginComponent:

async onSubmit(): Promise<void> {
// AuthService.login() internally calls handleAuthResponse after the request,
// routing to a challenge or to the success URL automatically
await this.auth.login(email, password);
}

Resend code

src/app/challenge/otp-verify.component.ts (simplified)
// Inside OtpVerifyComponent:

async resendCode(): Promise<void> {
const challenge = this.auth.getCurrentChallenge();
if (!challenge?.session) return;

const response = await this.auth.resendCode(challenge.session);
// response.destination contains the masked email/phone the code was sent to
}

Complete Controller

For reference, here is the complete NestJS controller with all basic auth routes from this guide in a single file:

Full auth.controller.ts
src/auth/auth.controller.ts
import {
Controller,
Post,
Get,
Body,
UseGuards,
HttpCode,
HttpStatus,
Req,
Query,
Inject,
BadRequestException,
} from '@nestjs/common';
import type { Request } from 'express';
import {
AuthService,
SignupDTO,
LoginDTO,
AuthResponseDTO,
AuthGuard,
CurrentUser,
Public,
IUser,
RespondChallengeDTO,
TokenResponse,
MFAService,
LogoutDTO,
RefreshTokenDTO,
LogoutResponseDTO,
ResendCodeDTO,
ResendCodeResponseDTO,
ForgotPasswordDTO,
ForgotPasswordResponseDTO,
ConfirmForgotPasswordDTO,
ConfirmForgotPasswordResponseDTO,
GetSetupDataDTO,
GetSetupDataResponseDTO,
GetUserSessionsResponseDTO,
UserResponseDTO,
ChangePasswordDTO,
ChangePasswordResponseDTO,
} from '@nauth-toolkit/nestjs';

@UseGuards(AuthGuard)
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
@Inject(MFAService)
private readonly mfaService?: MFAService,
) {}

// ── Public endpoints ────────────────────────────────────────────────────

@Public()
@Post('signup')
@HttpCode(HttpStatus.CREATED)
async signup(@Body() dto: SignupDTO): Promise<AuthResponseDTO> {
return await this.authService.signup(dto);
}

@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDTO): Promise<AuthResponseDTO> {
return await this.authService.login(dto);
}

@Public()
@Post('respond-challenge')
@HttpCode(HttpStatus.OK)
async respondToChallenge(@Body() dto: RespondChallengeDTO): Promise<AuthResponseDTO> {
return await this.authService.respondToChallenge(dto);
}

@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() dto: RefreshTokenDTO, @Req() req: Request): Promise<TokenResponse> {
return await this.authService.refreshToken({
refreshToken: dto?.refreshToken || req.cookies?.['nauth_refresh_token'],
});
}

@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() dto: ForgotPasswordDTO): Promise<ForgotPasswordResponseDTO> {
dto.baseUrl = `${process.env.FRONTEND_BASE_URL || 'http://localhost:4200'}/auth/reset-password`;
return await this.authService.forgotPassword(dto);
}

@Public()
@Post('forgot-password/confirm')
@HttpCode(HttpStatus.OK)
async confirmForgotPassword(@Body() dto: ConfirmForgotPasswordDTO): Promise<ConfirmForgotPasswordResponseDTO> {
return await this.authService.confirmForgotPassword(dto);
}

@Public()
@Post('challenge/resend')
@HttpCode(HttpStatus.OK)
async resendCode(@Body() dto: ResendCodeDTO): Promise<ResendCodeResponseDTO> {
return await this.authService.resendCode(dto);
}

@Public()
@Post('challenge/setup-data')
@HttpCode(HttpStatus.OK)
async getSetupData(@Body() dto: GetSetupDataDTO): Promise<GetSetupDataResponseDTO> {
if (!this.mfaService) {
throw new BadRequestException('MFA service is not available');
}
return await this.mfaService.getSetupData(dto);
}

// ── Authenticated endpoints ─────────────────────────────────────────────

@Get('logout')
@HttpCode(HttpStatus.OK)
async logout(@Query() dto: LogoutDTO): Promise<LogoutResponseDTO> {
return await this.authService.logout(dto);
}

@Get('profile')
async getProfile(@CurrentUser() user: IUser): Promise<UserResponseDTO> {
return UserResponseDTO.fromEntity(user);
}

@Get('sessions')
@HttpCode(HttpStatus.OK)
async getUserSessions(): Promise<GetUserSessionsResponseDTO> {
return await this.authService.getUserSessions();
}

@Post('change-password')
@HttpCode(HttpStatus.OK)
async changePassword(@Body() dto: ChangePasswordDTO): Promise<ChangePasswordResponseDTO> {
return await this.authService.changePassword(dto);
}
}

What's Next