NestJS authentication looks simple in most tutorials: create a login endpoint, compare a password, generate a JWT, and protect a route.
That is enough to understand the basic idea, but it is not enough for a real application.
Once authentication reaches production, you also need to think about password hashing, database constraints, token expiration, refresh tokens, logout, token revocation, validation, authorization, secrets, and what happens when a user changes their password or loses access to an account.
In this guide, I’ll build a practical authentication flow using NestJS, PostgreSQL, TypeORM, JWT access tokens, refresh tokens, DTO validation, and guards. More importantly, I’ll explain the decisions I would make when building this for a real project rather than stopping at tutorial-level code.
What We Are Building
Our authentication API will support:
POST /api/auth/register
POST /api/auth/login
POST /api/auth/refresh
POST /api/auth/logout
GET /api/auth/profile
GET /api/admin/users
The basic request flow will look like this:
User registers
↓
Validate request
↓
Hash password
↓
Save user in PostgreSQL
↓
Login
↓
Verify password
↓
Create access token
+
Create refresh token
↓
Protected routes use access token
↓
Expired access token?
↓
Refresh endpoint creates a new token
Authentication vs Authorization
These two terms are often mixed together, but they solve different problems.
| Concept | Question it answers |
|---|---|
| Authentication | Who is this user? |
| Authorization | What is this user allowed to do? |
A valid JWT may prove that a user is authenticated. It does not automatically mean that user should be allowed to delete another account, view admin reports, or change system settings.
I keep these responsibilities separate because authorization requirements usually become more complex as an application grows.
Step 1: Install the Required Packages
For this example, we will use PostgreSQL and TypeORM.
npm install @nestjs/typeorm typeorm pg
npm install @nestjs/config
npm install @nestjs/jwt
npm install class-validator class-transformer
npm install argon2
The responsibilities are roughly:
@nestjs/typeormandtypeorm— database integrationpg— PostgreSQL driver@nestjs/config— environment configuration@nestjs/jwt— JWT signing and verificationclass-validator— incoming request validationargon2— password hashing
Step 2: Configure Environment Variables
I never hard-code production database credentials or JWT secrets directly in the application source.
Create a .env file for local development:
PORT=3000
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=nestjs_auth
DATABASE_USER=postgres
DATABASE_PASSWORD=your_password
JWT_ACCESS_SECRET=replace_with_a_long_random_secret
JWT_REFRESH_SECRET=replace_with_another_long_random_secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
In production, I prefer secrets to come from the deployment environment or a secrets-management service rather than from a file committed to the repository.
Your .env file should also be excluded from Git.
.env
.env.local
.env.production
Step 3: Configure PostgreSQL with TypeORM
Configure the database through environment variables rather than embedding credentials in AppModule.
import { Module } from '@nestjs/common';
import {
ConfigModule,
ConfigService,
} from '@nestjs/config';
import {
TypeOrmModule,
} from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (
config: ConfigService,
) => ({
type: 'postgres',
host:
config.get<string>(
'DATABASE_HOST'
),
port:
config.get<number>(
'DATABASE_PORT'
),
username:
config.get<string>(
'DATABASE_USER'
),
password:
config.get<string>(
'DATABASE_PASSWORD'
),
database:
config.get<string>(
'DATABASE_NAME'
),
autoLoadEntities: true,
synchronize: false,
}),
}),
UsersModule,
AuthModule,
],
})
export class AppModule {}
Notice that I use:
synchronize: false
I would not depend on automatic schema synchronization in production. Database changes should be controlled through migrations so that changes are visible, reviewable, and repeatable.
Step 4: Create the User Entity
Our user entity needs enough information to support authentication without storing unnecessary secrets.
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 100,
})
name: string;
@Column({
unique: true,
})
email: string;
@Column({
select: false,
})
passwordHash: string;
@Column({
type: 'enum',
enum: UserRole,
default: UserRole.USER,
})
role: UserRole;
@Column({
nullable: true,
select: false,
})
refreshTokenHash:
string | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
There are two details here that I consider important.
First, the email column is unique:
@Column({
unique: true,
})
email: string;
The database should help enforce business invariants rather than trusting application code alone.
Second, password and refresh-token hashes use:
select: false
That reduces the chance of accidentally returning sensitive authentication fields from a normal user query.
Step 5: Create Registration and Login DTOs
Authentication endpoints are security boundaries, so I validate the request before it reaches the service layer.
Create register.dto.ts:
import {
IsEmail,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
export class RegisterDto {
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(10)
@MaxLength(128)
password: string;
}
Create login.dto.ts:
import {
IsEmail,
IsString,
} from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
password: string;
}
Step 6: Enable Global Validation
I prefer enabling NestJS validation globally so every DTO receives the same basic protection.
import {
ValidationPipe,
} from '@nestjs/common';
import {
NestFactory,
} from '@nestjs/core';
import {
AppModule,
} from './app.module';
async function bootstrap() {
const app =
await NestFactory.create(
AppModule
);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);
await app.listen(
process.env.PORT ?? 3000
);
}
bootstrap();
This gives us several protections:
- DTO validation happens automatically.
- Unexpected fields can be rejected.
- Incoming values can be transformed into expected types.
I prefer rejecting unknown fields on sensitive endpoints because silently accepting unexpected input can hide client mistakes.
Step 7: Build the Users Service
The authentication service should not need to understand every detail of TypeORM. I keep user persistence inside a users service.
import {
Injectable,
} from '@nestjs/common';
import {
InjectRepository,
} from '@nestjs/typeorm';
import {
Repository,
} from 'typeorm';
import {
User,
} from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users:
Repository<User>,
) {}
findByEmail(
email: string
) {
return this.users.findOne({
where: {
email:
email.toLowerCase(),
},
});
}
findByEmailWithSecrets(
email: string
) {
return this.users
.createQueryBuilder('user')
.addSelect(
'user.passwordHash'
)
.addSelect(
'user.refreshTokenHash'
)
.where(
'LOWER(user.email) = LOWER(:email)',
{
email,
}
)
.getOne();
}
create(data: Partial<User>) {
const user =
this.users.create(data);
return this.users.save(user);
}
async updateRefreshToken(
userId: number,
hash: string | null
) {
await this.users.update(
userId,
{
refreshTokenHash: hash,
}
);
}
}
The idea is that normal user queries do not automatically retrieve authentication secrets.
Step 8: Hash Passwords Before Saving Them
Never store a user’s raw password.
For this example, I am using Argon2.
import argon2 from 'argon2';
const passwordHash =
await argon2.hash(
dto.password
);
The database stores:
$argon2id$...
not:
MyPassword123
During login, verify the supplied password against the hash:
const valid =
await argon2.verify(
user.passwordHash,
dto.password
);
if (!valid) {
throw new UnauthorizedException(
'Invalid email or password'
);
}
I deliberately use the same public error message whether the email or password is wrong.
A login endpoint normally should not help an attacker determine which email addresses belong to real accounts.
Step 9: Register a User
Now we can implement registration.
import {
ConflictException,
Injectable,
} from '@nestjs/common';
import argon2 from 'argon2';
@Injectable()
export class AuthService {
constructor(
private readonly usersService:
UsersService,
private readonly jwtService:
JwtService,
private readonly config:
ConfigService,
) {}
async register(
dto: RegisterDto
) {
const email =
dto.email
.trim()
.toLowerCase();
const existingUser =
await this.usersService
.findByEmail(email);
if (existingUser) {
throw new ConflictException(
'An account with this email already exists'
);
}
const passwordHash =
await argon2.hash(
dto.password
);
const user =
await this.usersService.create({
name:
dto.name.trim(),
email,
passwordHash,
});
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}
I still keep the database-level unique constraint on email even though we check for an existing user in application code.
Why both?
Because two registration requests can reach the server at nearly the same time. Application checks alone do not replace a database constraint.
Step 10: Configure JWT Access and Refresh Tokens
I prefer short-lived access tokens and longer-lived refresh tokens rather than creating an access token that remains valid for weeks.
Access token
→ short lifetime
→ sent with API requests
Refresh token
→ longer lifetime
→ used to request new access tokens
A compromised access token therefore has a smaller useful window.
Configure the JWT module using environment configuration:
import {
JwtModule,
} from '@nestjs/jwt';
@Module({
imports: [
UsersModule,
JwtModule.register({
global: true,
}),
],
providers: [
AuthService,
],
controllers: [
AuthController,
],
})
export class AuthModule {}
I am intentionally passing the secrets when signing rather than putting one shared hard-coded secret inside the module.
Step 11: Generate the Token Pair
private async createTokens(
user: User
) {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
};
const [
accessToken,
refreshToken,
] = await Promise.all([
this.jwtService.signAsync(
payload,
{
secret:
this.config.getOrThrow(
'JWT_ACCESS_SECRET'
),
expiresIn:
this.config.get(
'JWT_ACCESS_EXPIRES_IN',
'15m'
),
}
),
this.jwtService.signAsync(
payload,
{
secret:
this.config.getOrThrow(
'JWT_REFRESH_SECRET'
),
expiresIn:
this.config.get(
'JWT_REFRESH_EXPIRES_IN',
'7d'
),
}
),
]);
return {
accessToken,
refreshToken,
};
}
I keep JWT payloads relatively small.
A token is not where I would place complete user profiles, permissions lists, addresses, preferences, and other large data structures.
Step 12: Implement Login
async login(
dto: LoginDto
) {
const user =
await this.usersService
.findByEmailWithSecrets(
dto.email
);
if (!user) {
throw new UnauthorizedException(
'Invalid email or password'
);
}
const passwordValid =
await argon2.verify(
user.passwordHash,
dto.password
);
if (!passwordValid) {
throw new UnauthorizedException(
'Invalid email or password'
);
}
const tokens =
await this.createTokens(
user
);
const refreshTokenHash =
await argon2.hash(
tokens.refreshToken
);
await this.usersService
.updateRefreshToken(
user.id,
refreshTokenHash
);
return tokens;
}
Notice something important here: I do not store the raw refresh token in the database.
I store a hash of it.
Client
→ receives refresh token
Database
→ stores hash of refresh token
This is similar to the thinking behind password storage: if the database is exposed, raw long-lived credentials should not be sitting there unnecessarily.
Step 13: Create the Authentication Controller
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
} from '@nestjs/common';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService:
AuthService,
) {}
@Post('register')
register(
@Body() dto: RegisterDto
) {
return this.authService
.register(dto);
}
@HttpCode(HttpStatus.OK)
@Post('login')
login(
@Body() dto: LoginDto
) {
return this.authService
.login(dto);
}
}
Your routes now become:
POST /api/auth/register
POST /api/auth/login
Step 14: Protect Routes with an Authentication Guard
A protected endpoint should reject requests that do not contain a valid access token.
Create an authentication guard:
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
ConfigService,
} from '@nestjs/config';
import {
JwtService,
} from '@nestjs/jwt';
import type {
Request,
} from 'express';
@Injectable()
export class AuthGuard
implements CanActivate
{
constructor(
private readonly jwtService:
JwtService,
private readonly config:
ConfigService,
) {}
async canActivate(
context: ExecutionContext
) {
const request =
context
.switchToHttp()
.getRequest<Request>();
const token =
this.extractToken(
request
);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload =
await this.jwtService
.verifyAsync(
token,
{
secret:
this.config
.getOrThrow(
'JWT_ACCESS_SECRET'
),
}
);
request['user'] =
payload;
return true;
} catch {
throw new UnauthorizedException();
}
}
private extractToken(
request: Request
) {
const [
type,
token,
] =
request.headers
.authorization
?.split(' ') ?? [];
return type === 'Bearer'
? token
: undefined;
}
}
Step 15: Create a Protected Profile Route
import {
Controller,
Get,
Req,
UseGuards,
} from '@nestjs/common';
@Controller('auth')
export class AuthController {
@UseGuards(AuthGuard)
@Get('profile')
getProfile(
@Req() request
) {
return {
user:
request.user,
};
}
}
The client sends:
Authorization: Bearer YOUR_ACCESS_TOKEN
Without a valid token, the route returns 401 Unauthorized.
Step 16: Implement Refresh Tokens
When the short-lived access token expires, I do not want the user to type their password again every 15 minutes.
The refresh endpoint handles this.
async refresh(
refreshToken: string
) {
let payload:
{
sub: number;
email: string;
};
try {
payload =
await this.jwtService
.verifyAsync(
refreshToken,
{
secret:
this.config
.getOrThrow(
'JWT_REFRESH_SECRET'
),
}
);
} catch {
throw new UnauthorizedException(
'Invalid refresh token'
);
}
const user =
await this.usersService
.findByEmailWithSecrets(
payload.email
);
if (
!user ||
!user.refreshTokenHash
) {
throw new UnauthorizedException(
'Invalid refresh token'
);
}
const valid =
await argon2.verify(
user.refreshTokenHash,
refreshToken
);
if (!valid) {
throw new UnauthorizedException(
'Invalid refresh token'
);
}
const tokens =
await this.createTokens(
user
);
const newRefreshHash =
await argon2.hash(
tokens.refreshToken
);
await this.usersService
.updateRefreshToken(
user.id,
newRefreshHash
);
return tokens;
}
This also rotates the refresh token instead of repeatedly returning the same credential.
Why I Prefer Refresh Token Rotation
A reusable refresh token that stays valid for months creates a larger security window if it is stolen.
A simplified rotation flow looks like this:
Refresh Token A
↓
Client requests refresh
↓
Verify A
↓
Issue:
Access Token B
Refresh Token B
↓
Replace stored hash
↓
Token A no longer matches
For higher-security systems, I would take this further with token-family tracking, reuse detection, per-device sessions, and server-side session records rather than one refresh-token hash directly on the user.
Step 17: Implement Logout
With this simplified approach, logout means invalidating the stored refresh credential.
async logout(
userId: number
) {
await this.usersService
.updateRefreshToken(
userId,
null
);
return {
message:
'Logged out successfully',
};
}
The current access token may remain valid until it expires.
That is one reason I keep access tokens short-lived.
Should JWTs Be Stored in localStorage?
This question does not have one universal answer because the correct storage model depends on your architecture and threat model.
For browser applications handling sensitive authentication, I am cautious about storing long-lived authentication credentials in JavaScript-accessible storage.
A common architecture is:
Access token
→ short-lived
→ memory / controlled client state
Refresh token
→ Secure
→ HttpOnly
→ SameSite cookie
An HttpOnly cookie prevents normal browser JavaScript from reading the refresh token, although cookie-based authentication introduces its own concerns such as CSRF configuration and cookie policy.
The important point is that token storage should be an explicit security decision not simply “put everything in localStorage because a tutorial did it.”
Step 18: Add Role-Based Authorization
Authentication tells us who the user is. Now we can decide what that user is allowed to do.
Suppose only administrators can list all users.
Create a roles decorator:
import {
SetMetadata,
} from '@nestjs/common';
export const ROLES_KEY =
'roles';
export const Roles =
(...roles: string[]) =>
SetMetadata(
ROLES_KEY,
roles
);
Then create a guard:
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import {
Reflector,
} from '@nestjs/core';
@Injectable()
export class RolesGuard
implements CanActivate
{
constructor(
private readonly reflector:
Reflector,
) {}
canActivate(
context: ExecutionContext
) {
const requiredRoles =
this.reflector
.getAllAndOverride<string[]>(
ROLES_KEY,
[
context.getHandler(),
context.getClass(),
]
);
if (!requiredRoles) {
return true;
}
const request =
context
.switchToHttp()
.getRequest();
return requiredRoles
.includes(
request.user.role
);
}
}
Now protect the route:
@Roles('admin')
@UseGuards(
AuthGuard,
RolesGuard
)
@Get('users')
findAllUsers() {
return this.usersService
.findAll();
}
For simple systems, RBAC is often enough.
For more complex applications, permissions may depend on ownership, organization membership, resource state, subscription plan, or other business rules. At that point, I usually move beyond a simple role string.
Step 19: Never Trust a Role Sent by the Client
One authentication mistake would be allowing a public registration request like:
{
"name": "Attacker",
"email": "attacker@example.com",
"password": "password",
"role": "admin"
}
and then blindly spreading the DTO into your database model.
// Avoid this pattern
const user =
repository.create({
...dto,
});
Public registration should explicitly control which fields can be written.
repository.create({
name: dto.name,
email: dto.email,
passwordHash,
role: UserRole.USER,
});
Authorization-critical values should come from trusted server-side rules.
Step 20: Add Rate Limiting to Authentication Routes
A login endpoint is an obvious target for brute-force and credential-stuffing attempts.
I would rate-limit endpoints such as:
- login
- registration
- password reset requests
- verification-code requests
- refresh endpoints where appropriate
The exact thresholds depend on the product, infrastructure, and user behavior. I prefer combining rate limiting with monitoring rather than picking one arbitrary number and forgetting about it.
Step 21: Password Reset Needs Its Own Secure Flow
Password-reset links should not simply contain the user’s permanent authentication token.
A safer design is:
User requests password reset
↓
Generate random one-time token
↓
Store token hash + expiry
↓
Email raw token to user
↓
User submits new password + token
↓
Verify token
↓
Update password
↓
Invalidate token
↓
Revoke existing sessions
I also avoid confirming whether an email exists during the password-reset request.
A response such as:
If an account exists for this email, password reset instructions have been sent.
reveals less account information than:
No user exists with that email address.
What Happens When a User Changes Their Password?
In many applications, changing a password should invalidate existing refresh sessions.
Otherwise someone who already stole a refresh token may continue creating new access tokens even after the legitimate user changes their password.
For a basic implementation:
Password changed
↓
Update password hash
↓
Clear refresh token hash
↓
Require login again
For multi-device applications, I prefer a proper sessions table so individual sessions can be revoked separately.
A Better Session Model for Larger Applications
Storing one refresh-token hash on the user works for a tutorial and some simple applications, but it has a limitation: the user effectively has one refresh session.
For products where users log in from phones, tablets, and multiple browsers, I prefer a separate session table.
users
├── id
├── email
└── password_hash
sessions
├── id
├── user_id
├── refresh_token_hash
├── device
├── ip_address
├── expires_at
├── revoked_at
└── created_at
That gives you features such as:
- log out one device
- log out all devices
- show active sessions
- revoke suspicious sessions
- track refresh-token rotation
- implement session expiration rules
This is the kind of architectural difference I consider when moving from a tutorial implementation to a production system.
Common NestJS Authentication Mistakes
1. Storing plain-text passwords
This should never happen. Store an appropriate password hash instead.
2. Returning password hashes from APIs
Password hashes should not appear in normal user DTOs or API responses.
3. Hard-coding JWT secrets
secret: 'my-secret'
Authentication secrets belong outside the source code.
4. Creating extremely long-lived access tokens
A short-lived access token limits the useful lifetime of a stolen token.
5. Storing raw refresh tokens in the database
If you do not need the original token later, storing a verifiable hash reduces exposure.
6. Putting sensitive data inside JWT payloads
A signed JWT is not automatically secret. Do not treat its payload as encrypted private storage.
7. Trusting authorization fields from the client
Roles and permissions must come from trusted server-side state.
8. Forgetting token revocation
Think about what happens after logout, password changes, account compromise, or administrator suspension.
9. Using synchronize: true in production
Use controlled database migrations rather than allowing automatic schema synchronization to modify production data structures.
10. Logging credentials
Do not log passwords, raw refresh tokens, JWTs, authorization headers, reset tokens, or other authentication secrets.
What I Would Do Differently in a Real NestJS Project
For a small project, the implementation above is a good foundation.
For a production system with important customer data, I would usually extend it in several areas.
Use a sessions table
This gives better multi-device control than storing one refresh-token hash directly on the user.
Add email verification
I would not automatically trust ownership of an email address simply because someone entered it during registration.
Add password-reset tokens
These should be random, temporary, single-use credentials with an expiry time.
Add authentication events
I want visibility into events such as successful login, failed login, password change, account lockout, session revocation, and password-reset activity.
Add rate limiting
Especially around endpoints that can be abused for brute-force attacks or expensive operations.
Add MFA where the product warrants it
For administrator accounts, financial systems, or sensitive applications, password-only authentication may not be enough.
Use migrations from day one
Authentication tables tend to evolve. I want every database change to be reproducible across local, staging, and production environments.
Recommended Project Structure
src/
├── auth/
│ ├── dto/
│ │ ├── login.dto.ts
│ │ ├── refresh.dto.ts
│ │ └── register.dto.ts
│ ├── guards/
│ │ ├── auth.guard.ts
│ │ └── roles.guard.ts
│ ├── decorators/
│ │ └── roles.decorator.ts
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ └── auth.service.ts
│
├── users/
│ ├── entities/
│ │ └── user.entity.ts
│ ├── users.module.ts
│ └── users.service.ts
│
├── config/
│
├── app.module.ts
└── main.ts
I would not create dozens of abstractions on day one. This structure is enough to keep authentication and user responsibilities separated without making the application difficult to navigate.
NestJS Authentication Flow at a Glance
REGISTER
Request
→ DTO validation
→ Normalize email
→ Check existing account
→ Hash password
→ Save user
LOGIN
Email + Password
→ Find user
→ Verify Argon2 hash
→ Create access token
→ Create refresh token
→ Hash refresh token
→ Save refresh hash
PROTECTED REQUEST
Bearer access token
→ AuthGuard
→ Verify JWT
→ Attach user payload
→ Controller
REFRESH
Refresh token
→ Verify JWT signature
→ Load user/session
→ Verify stored hash
→ Rotate token pair
→ Store new refresh hash
LOGOUT
Authenticated user
→ Delete/revoke refresh credential
Authentication Security Checklist
- Validate all authentication DTOs.
- Normalize email addresses consistently.
- Enforce unique identities at the database level.
- Hash passwords using a suitable password-hashing algorithm.
- Never return password hashes.
- Keep JWT secrets outside source control.
- Use short-lived access tokens.
- Protect refresh credentials carefully.
- Rotate refresh tokens where appropriate.
- Implement logout and revocation.
- Rate-limit authentication endpoints.
- Do not leak whether an account exists unnecessarily.
- Keep server roles and permissions authoritative.
- Use controlled database migrations.
- Never log credentials or tokens.
- Use HTTPS in production.
- Consider MFA for sensitive accounts.
- Monitor suspicious authentication activity.
Final Thoughts
Building NestJS authentication is not difficult if all you need is a login endpoint and one JWT.
The real engineering work starts when you ask what happens after that:
- What happens when the access token expires?
- How does logout invalidate a session?
- What happens if a refresh token is stolen?
- How does a user log in from multiple devices?
- What happens after a password change?
- How do administrators revoke access?
- Where are authentication events monitored?
That is the difference between a JWT tutorial and an authentication system you can maintain in a real application.
My preferred starting point is straightforward:
PostgreSQL
+
validated DTOs
+
Argon2 password hashes
+
short-lived JWT access tokens
+
protected refresh credentials
+
NestJS guards
+
explicit authorization
+
controlled migrations
Then I add sessions, email verification, password recovery, MFA, auditing, and more advanced authorization only when the product actually requires them.
If you are still learning NestJS architecture, read my guide to building a production-ready REST API with NestJS first. It explains the controller, service, DTO, and database structure that this authentication system builds on.
You may also find my production guide to error handling in Node.js useful, because authentication errors should follow the same consistent application-wide error strategy as the rest of your API.



