nestjs-auth-rbac
Authentication and authorization deep-dive for NestJS covering JWT, sessions, refresh tokens, RBAC, permissions, guards, decorators, OAuth2, multi-tenancy, and security hardening. Use when implementing auth flows, role-based access control, permission systems, or securing NestJS APIs.
Nestjs Auth Rbac — Compiled Guide
Version: 1.0.0
This file is auto-generated from the individual guide files in
guides/. Do not edit directly.
Overview
Authentication and authorization deep-dive for NestJS covering JWT, sessions, refresh tokens, RBAC, permissions, guards, decorators, OAuth2, multi-tenancy, and security hardening. Use when implementing auth flows, role-based access control, permission systems, or securing NestJS APIs.
Table of Contents
- >JWT Authentication: Secure Cookie Transport
- >JWT Authentication: JWT Passport Strategy
- >JWT Authentication: Token Generation Service
- >Guards & Decorators: Auth Guard with Public Route Bypass
- >Guards & Decorators: Guard Composition (AND/OR Logic)
- >Guards & Decorators: Roles Guard and Decorator
- >Role-Based Access Control: Request-Level Role Resolution
- >Role-Based Access Control: RBAC Database Schema
- >Role-Based Access Control: RBAC Service with Role Hierarchy
- >Permission System: Permissions Decorator Patterns
- >Permission System: Fine-Grained Permissions
- >Permission System: Resource Ownership Checks
- >Refresh Tokens: Token Revocation and Blacklisting
- >Refresh Tokens: Refresh Token Rotation
- >Session Auth: Passport Session Serialization
- >Session Auth: Session Authentication Setup
- >OAuth2 & Social: GitHub OAuth2 Integration
- >OAuth2 & Social: Google OAuth2 Integration
- >OAuth2 & Social: Account Linking
- >Multi-Tenancy: Tenant Identification
- >Multi-Tenancy: Tenant Data Isolation
- >Security Hardening: Brute Force Protection
- >Security Hardening: CSRF and CORS Configuration
- >Security Hardening: Password Hashing and Validation
- >Testing Auth: E2E Testing Protected Endpoints
- >Testing Auth: Mocking Auth in Unit Tests
1. Secure Cookie Transport
Store tokens in HTTP-only cookies for browser-based applications to prevent XSS token theft.
Setting Cookies
// modules/auth/auth.controller.ts
import { Response } from 'express';
@Controller('auth')
export class AuthController {
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const tokens = await this.authService.login(dto.email, dto.password);
this.setTokenCookies(res, tokens);
return { message: 'Login successful' };
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) throw new UnauthorizedException('No refresh token');
const tokens = await this.authService.refreshTokens(refreshToken);
this.setTokenCookies(res, tokens);
return { message: 'Tokens refreshed' };
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie('access_token');
res.clearCookie('refresh_token');
}
private setTokenCookies(res: Response, tokens: AuthTokens) {
res.cookie('access_token', tokens.accessToken, {
httpOnly: true, // not accessible via JavaScript
secure: true, // only sent over HTTPS
sameSite: 'strict', // prevents CSRF
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh', // only sent to refresh endpoint
});
}
}Cookie Parser Setup
// main.ts
import * as cookieParser from 'cookie-parser';
app.use(cookieParser());JWT Strategy for Cookies
@Injectable()
export class JwtCookieStrategy extends PassportStrategy(Strategy, 'jwt-cookie') {
constructor(config: ConfigService) {
super({
jwtFromRequest: (req) => req?.cookies?.access_token ?? null,
ignoreExpiration: false,
secretOrKey: config.get('auth.jwtSecret'),
});
}
validate(payload: JwtPayload) {
return { id: payload.sub, email: payload.email, role: payload.role };
}
}Rules
- >Always set
httpOnly: true— prevents JavaScript access (XSS protection) - >Always set
secure: truein production — cookies only sent over HTTPS - >Use
sameSite: 'strict'for same-site apps,'lax'if you need cross-site navigation - >Set
path: '/auth/refresh'on refresh token cookie — limits exposure surface - >Use
@Res({ passthrough: true })to set cookies while keeping NestJS response handling - >Clear both cookies on logout — don't just invalidate the token server-side
2. JWT Passport Strategy
Configure Passport JWT strategy for stateless authentication in NestJS.
Strategy Implementation
// modules/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: string; // user ID
email: string;
role: string;
iat?: number;
exp?: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('auth.jwtSecret'),
});
}
// Called after JWT is verified — return value becomes req.user
validate(payload: JwtPayload) {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
};
}
}Extract from Cookie (Alternative)
@Injectable()
export class JwtCookieStrategy extends PassportStrategy(Strategy, 'jwt-cookie') {
constructor(config: ConfigService) {
super({
jwtFromRequest: (req) => req?.cookies?.access_token ?? null,
ignoreExpiration: false,
secretOrKey: config.get<string>('auth.jwtSecret'),
});
}
validate(payload: JwtPayload) {
return { id: payload.sub, email: payload.email, role: payload.role };
}
}Auth Module Registration
// modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('auth.jwtSecret'),
signOptions: { expiresIn: config.get<string>('auth.jwtExpiresIn', '15m') },
}),
}),
],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}Rules
- >Keep JWT payloads small: user ID, email, role — avoid embedding full user objects
- >Set short expiration (15m) for access tokens — use refresh tokens for longevity
- >Never store sensitive data in JWT (passwords, secrets) — JWTs are base64, not encrypted
- >Use
ExtractJwt.fromAuthHeaderAsBearerToken()for API clients, cookies for browser apps - >The
validate()return value becomesreq.user— shape it for downstream consumption - >Store JWT secret in environment variables — never hardcode
3. Token Generation Service
Centralize token creation and validation in a dedicated service.
Auth Service
// modules/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import { verify } from 'argon2';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly usersService: UsersService,
private readonly config: ConfigService,
) {}
async login(email: string, password: string): Promise<AuthTokens> {
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const isValid = await verify(user.passwordHash, password);
if (!isValid) {
throw new UnauthorizedException('Invalid credentials');
}
return this.generateTokens(user.id, user.email, user.role);
}
async register(dto: RegisterDto): Promise<AuthTokens> {
const user = await this.usersService.create(dto);
return this.generateTokens(user.id, user.email, user.role);
}
private async generateTokens(
userId: string,
email: string,
role: string,
): Promise<AuthTokens> {
const payload: JwtPayload = { sub: userId, email, role };
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
expiresIn: this.config.get('auth.jwtExpiresIn', '15m'),
}),
this.jwtService.signAsync(
{ sub: userId, type: 'refresh' },
{
secret: this.config.get('auth.refreshSecret'),
expiresIn: this.config.get('auth.refreshExpiresIn', '7d'),
},
),
]);
return { accessToken, refreshToken };
}
async verifyRefreshToken(token: string): Promise<{ sub: string }> {
try {
return await this.jwtService.verifyAsync(token, {
secret: this.config.get('auth.refreshSecret'),
});
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
}Auth Controller
// modules/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() dto: LoginDto) {
return this.authService.login(dto.email, dto.password);
}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken);
}
@Post('logout')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.NO_CONTENT)
logout(@CurrentUser() user: AuthUser) {
return this.authService.logout(user.id);
}
}Rules
- >Use the same error message for invalid email and wrong password — prevents user enumeration
- >Generate access and refresh tokens in parallel with
Promise.all - >Use different secrets for access and refresh tokens — separate compromise blast radius
- >Keep access tokens short-lived (15m), refresh tokens longer (7d)
- >Use
argon2for password verification — preferred over bcrypt for modern applications - >Return both tokens from login/register — client stores refresh token securely
4. Auth Guard with Public Route Bypass
Create a global auth guard that protects all routes by default, with a @Public() decorator to opt out.
Public Decorator
// common/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);JWT Auth Guard
// common/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
// Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}Global Registration
// app.module.ts
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}Usage
@Controller('auth')
export class AuthController {
@Public()
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Public()
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
// This route is protected (no @Public)
@Post('change-password')
changePassword(@CurrentUser() user: AuthUser, @Body() dto: ChangePasswordDto) {
return this.authService.changePassword(user.id, dto);
}
}
@Controller('health')
@Public() // entire controller is public
export class HealthController {
@Get()
check() {
return { status: 'ok' };
}
}CurrentUser Decorator
// common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthUser {
id: string;
email: string;
role: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof AuthUser | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as AuthUser;
return data ? user?.[data] : user;
},
);
// Usage:
// @CurrentUser() user: AuthUser → full user object
// @CurrentUser('id') userId: string → just the IDRules
- >Register
JwtAuthGuardglobally — all routes are protected by default - >Use
@Public()to opt specific routes out of authentication - >Use
reflector.getAllAndOverrideto check both handler and class-level metadata - >Create a
@CurrentUser()decorator to extract the authenticated user cleanly - >Define an
AuthUserinterface for the shape ofreq.user— consistent typing across the app - >Place
@Public()on auth controller routes (login, register) and health checks
5. Guard Composition (AND/OR Logic)
Combine multiple guards with AND/OR logic for complex authorization requirements.
AND Logic (Default Behavior)
// Multiple global guards run sequentially — ALL must pass (AND)
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // must be authenticated
{ provide: APP_GUARD, useClass: RolesGuard }, // must have required role
{ provide: APP_GUARD, useClass: ThrottlerGuard }, // must not be rate-limited
],
})
export class AppModule {}
// Route-level: multiple @UseGuards also runs as AND
@Post()
@UseGuards(JwtAuthGuard, RolesGuard, IpWhitelistGuard)
create(@Body() dto: CreateDto) {}OR Logic (Custom Composite Guard)
// common/guards/any-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { ApiKeyGuard } from './api-key.guard';
@Injectable()
export class AnyAuthGuard implements CanActivate {
constructor(
private readonly jwtGuard: JwtAuthGuard,
private readonly apiKeyGuard: ApiKeyGuard,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Try JWT first
try {
const jwtResult = await this.jwtGuard.canActivate(context);
if (jwtResult) return true;
} catch {}
// Fall back to API key
try {
const apiKeyResult = await this.apiKeyGuard.canActivate(context);
if (apiKeyResult) return true;
} catch {}
return false;
}
}
// Usage: authenticate with JWT OR API key
@Controller('api')
@UseGuards(AnyAuthGuard)
export class ApiController {}Role + Ownership Guard
// common/guards/owner-or-admin.guard.ts
@Injectable()
export class OwnerOrAdminGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
const resourceUserId = request.params.userId ?? request.params.id;
// Admins can access anything
if (user.role === 'admin') return true;
// Users can only access their own resources
if (user.id === resourceUserId) return true;
throw new ForbiddenException('You can only access your own resources');
}
}
// Usage
@Controller('users')
export class UsersController {
@Get(':id/profile')
@UseGuards(OwnerOrAdminGuard)
getProfile(@Param('id', ParseUUIDPipe) id: string) {}
@Patch(':id')
@UseGuards(OwnerOrAdminGuard)
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto) {}
}Conditional Guard Factory
// common/guards/require-any-role.guard.ts
export function RequireAnyRole(...roles: string[]) {
@Injectable()
class RoleCheckGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const user = context.switchToHttp().getRequest().user;
return roles.includes(user?.role);
}
}
return mixin(RoleCheckGuard);
}
// Usage
@Post()
@UseGuards(RequireAnyRole('admin', 'editor'))
create() {}Rules
- >Multiple
@UseGuards(A, B, C)or global guards run as AND — all must pass - >For OR logic, create a composite guard that tries each sub-guard and succeeds if any passes
- >Owner-or-admin is a common pattern — check resource ownership with admin bypass
- >Use
mixin()for parameterized guard factories to preserve DI scope - >Keep guard logic simple — complex authorization should be in a service, not a guard
- >Always handle guard errors gracefully — catch exceptions in composite guards
6. Roles Guard and Decorator
Restrict route access based on user roles.
Roles Decorator
// common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);Roles Guard
// common/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// No roles required — allow access
if (!requiredRoles?.length) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
throw new ForbiddenException('Authentication required');
}
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) {
throw new ForbiddenException(
`Required role: ${requiredRoles.join(' or ')}`,
);
}
return true;
}
}Global Registration (After Auth Guard)
// app.module.ts
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // runs first
{ provide: APP_GUARD, useClass: RolesGuard }, // runs second
],
})
export class AppModule {}Usage
@Controller('admin/users')
@Roles('admin') // all routes in this controller require admin
export class AdminUsersController {
@Get()
findAll() {
return this.usersService.findAll();
}
@Delete(':id')
@Roles('super_admin') // override: requires super_admin
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.remove(id);
}
}
@Controller('posts')
export class PostsController {
@Get()
findAll() {} // any authenticated user
@Post()
@Roles('admin', 'moderator') // admin OR moderator
create(@Body() dto: CreatePostDto) {}
@Delete(':id')
@Roles('admin')
remove(@Param('id', ParseUUIDPipe) id: string) {}
}Rules
- >Register
RolesGuardafterJwtAuthGuard— roles check needsreq.userto be populated - >Use
getAllAndOverride— handler-level@Roles()overrides class-level - >If no
@Roles()decorator is present, the guard allows access (authenticated but no role requirement) - >Multiple roles in
@Roles('admin', 'moderator')means OR — user needs any one of them - >Throw
ForbiddenException(403) for authorization failures, notUnauthorizedException(401) - >Keep role strings as constants — avoid magic strings scattered across controllers
7. Request-Level Role Resolution
Attach permissions to the request early so guards and services can check them without repeated DB queries.
Permissions Interceptor
// common/interceptors/permissions.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { RbacService } from '@/modules/auth/rbac.service';
@Injectable()
export class PermissionsInterceptor implements NestInterceptor {
constructor(private readonly rbacService: RbacService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (user?.id) {
// Attach permissions to request for downstream use
request.permissions = await this.rbacService.getUserPermissions(user.id);
}
return next.handle();
}
}Global Registration
// app.module.ts
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_INTERCEPTOR, useClass: PermissionsInterceptor },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
})
export class AppModule {}Permissions Guard (Using Pre-Loaded Permissions)
// common/guards/permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>('permissions', [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const request = context.switchToHttp().getRequest();
const userPermissions: string[] = request.permissions ?? [];
const hasPermission = required.some((p) => userPermissions.includes(p));
if (!hasPermission) {
throw new ForbiddenException(
`Missing required permission: ${required.join(' or ')}`,
);
}
return true;
}
}Accessing Permissions in Services
// Use @Inject(REQUEST) to access request-scoped permissions
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class PostsService {
constructor(@Inject(REQUEST) private readonly request: Request) {}
async update(postId: string, dto: UpdatePostDto) {
const post = await this.postsRepo.findById(postId);
const permissions = (this.request as any).permissions as string[];
// Non-admins can only edit their own posts
if (!permissions.includes('posts:update-any') && post.authorId !== this.request.user.id) {
throw new ForbiddenException('You can only edit your own posts');
}
return this.postsRepo.update(postId, dto);
}
}Rules
- >Load permissions once per request in an interceptor — avoids repeated DB/cache queries
- >Interceptors run after guards, so
JwtAuthGuardmust populatereq.userfirst - >The permissions guard reads from
req.permissions(pre-loaded), not from the database - >Use request-scoped services when business logic needs permission checks beyond guards
- >Order matters: Auth Guard → Permissions Interceptor → Roles/Permissions Guard
- >Keep permissions as a flat string array on the request — simple and fast to check
8. RBAC Database Schema
Design a flexible role and permission schema using Drizzle ORM.
Schema Definition
// shared/database/schema/roles.schema.ts
import { pgTable, uuid, varchar, text, timestamp, primaryKey, boolean } from 'drizzle-orm/pg-core';
import { users } from './users.schema';
export const roles = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 50 }).notNull().unique(), // 'admin', 'editor', 'viewer'
description: text('description'),
isDefault: boolean('is_default').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const permissions = pgTable('permissions', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 100 }).notNull().unique(), // 'users:create', 'posts:delete'
description: text('description'),
resource: varchar('resource', { length: 50 }).notNull(), // 'users', 'posts'
action: varchar('action', { length: 50 }).notNull(), // 'create', 'read', 'update', 'delete'
});
export const rolePermissions = pgTable(
'role_permissions',
{
roleId: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
permissionId: uuid('permission_id')
.notNull()
.references(() => permissions.id, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.roleId, table.permissionId] }),
],
);
export const userRoles = pgTable(
'user_roles',
{
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
assignedAt: timestamp('assigned_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.userId, table.roleId] }),
],
);Relations
// shared/database/schema/relations.ts
export const rolesRelations = relations(roles, ({ many }) => ({
rolePermissions: many(rolePermissions),
userRoles: many(userRoles),
}));
export const permissionsRelations = relations(permissions, ({ many }) => ({
rolePermissions: many(rolePermissions),
}));
export const rolePermissionsRelations = relations(rolePermissions, ({ one }) => ({
role: one(roles, { fields: [rolePermissions.roleId], references: [roles.id] }),
permission: one(permissions, { fields: [rolePermissions.permissionId], references: [permissions.id] }),
}));
export const userRolesRelations = relations(userRoles, ({ one }) => ({
user: one(users, { fields: [userRoles.userId], references: [users.id] }),
role: one(roles, { fields: [userRoles.roleId], references: [roles.id] }),
}));Seed Default Roles
// shared/database/seeds/roles.seed.ts
const defaultRoles = [
{ name: 'admin', description: 'Full system access', isDefault: false },
{ name: 'editor', description: 'Can create and edit content', isDefault: false },
{ name: 'viewer', description: 'Read-only access', isDefault: true },
];
const defaultPermissions = [
{ name: 'users:create', resource: 'users', action: 'create' },
{ name: 'users:read', resource: 'users', action: 'read' },
{ name: 'users:update', resource: 'users', action: 'update' },
{ name: 'users:delete', resource: 'users', action: 'delete' },
{ name: 'posts:create', resource: 'posts', action: 'create' },
{ name: 'posts:read', resource: 'posts', action: 'read' },
{ name: 'posts:update', resource: 'posts', action: 'update' },
{ name: 'posts:delete', resource: 'posts', action: 'delete' },
];Rules
- >Use
resource:actionnaming for permissions (e.g.,users:create,posts:delete) - >Use junction tables (
role_permissions,user_roles) for many-to-many relationships - >One role should be marked
isDefault: true— assigned to new users automatically - >Seed default roles and permissions in migrations or seed scripts — not at runtime
- >Users can have multiple roles — permissions are the union of all assigned role permissions
- >Always
cascadedeletes on junction tables to prevent orphaned records
9. RBAC Service with Role Hierarchy
Implement role-based authorization with optional hierarchy support.
RBAC Service
// modules/auth/rbac.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { DRIZZLE, DrizzleDB } from '@/shared/database/drizzle.provider';
import { userRoles, rolePermissions, roles, permissions } from '@/shared/database/schema';
import { eq, inArray } from 'drizzle-orm';
@Injectable()
export class RbacService {
// Role hierarchy: higher roles inherit lower role permissions
private readonly roleHierarchy: Record<string, string[]> = {
admin: ['editor', 'viewer'],
editor: ['viewer'],
viewer: [],
};
constructor(
@Inject(DRIZZLE) private readonly db: DrizzleDB,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}
async getUserPermissions(userId: string): Promise<string[]> {
const cacheKey = `user:${userId}:permissions`;
const cached = await this.cache.get<string[]>(cacheKey);
if (cached) return cached;
// Get user's roles
const userRoleRecords = await this.db.query.userRoles.findMany({
where: eq(userRoles.userId, userId),
with: { role: true },
});
const roleNames = userRoleRecords.map((ur) => ur.role.name);
// Expand with hierarchy
const allRoles = new Set<string>();
for (const roleName of roleNames) {
allRoles.add(roleName);
const inherited = this.roleHierarchy[roleName] ?? [];
inherited.forEach((r) => allRoles.add(r));
}
// Get all permissions for expanded roles
const roleRecords = await this.db
.select({ id: roles.id })
.from(roles)
.where(inArray(roles.name, [...allRoles]));
const roleIds = roleRecords.map((r) => r.id);
if (roleIds.length === 0) return [];
const perms = await this.db
.select({ name: permissions.name })
.from(rolePermissions)
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
.where(inArray(rolePermissions.roleId, roleIds));
const permissionNames = [...new Set(perms.map((p) => p.name))];
await this.cache.set(cacheKey, permissionNames, 300_000); // 5 min cache
return permissionNames;
}
async hasPermission(userId: string, permission: string): Promise<boolean> {
const permissions = await this.getUserPermissions(userId);
return permissions.includes(permission);
}
async hasAnyPermission(userId: string, requiredPermissions: string[]): Promise<boolean> {
const permissions = await this.getUserPermissions(userId);
return requiredPermissions.some((p) => permissions.includes(p));
}
async hasAllPermissions(userId: string, requiredPermissions: string[]): Promise<boolean> {
const permissions = await this.getUserPermissions(userId);
return requiredPermissions.every((p) => permissions.includes(p));
}
async invalidateUserCache(userId: string) {
await this.cache.del(`user:${userId}:permissions`);
}
}Assigning Roles
@Injectable()
export class RolesService {
async assignRole(userId: string, roleName: string) {
const role = await this.db.query.roles.findFirst({
where: eq(roles.name, roleName),
});
if (!role) throw new NotFoundException(`Role ${roleName} not found`);
await this.db
.insert(userRoles)
.values({ userId, roleId: role.id })
.onConflictDoNothing();
await this.rbacService.invalidateUserCache(userId);
}
async removeRole(userId: string, roleName: string) {
const role = await this.db.query.roles.findFirst({
where: eq(roles.name, roleName),
});
if (!role) return;
await this.db
.delete(userRoles)
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, role.id)));
await this.rbacService.invalidateUserCache(userId);
}
}Rules
- >Cache user permissions — don't query the database on every request
- >Invalidate permission cache when roles or permissions change
- >Use role hierarchy to reduce permission duplication (admin inherits editor inherits viewer)
- >Provide
hasAnyPermission(OR) andhasAllPermissions(AND) for flexible checks - >Keep role hierarchy configuration simple — deep hierarchies are hard to reason about
- >Use
onConflictDoNothingwhen assigning roles to handle idempotent operations
10. Permissions Decorator Patterns
Create composable decorators that combine auth, roles, and permissions for clean controller code.
Composed Auth Decorator
// common/decorators/auth.decorator.ts
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse, ApiForbiddenResponse } from '@nestjs/swagger';
export function Auth(...permissions: string[]) {
return applyDecorators(
SetMetadata('permissions', permissions),
UseGuards(JwtAuthGuard, PermissionsGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Authentication required' }),
ApiForbiddenResponse({ description: 'Insufficient permissions' }),
);
}
// Usage — clean, single decorator
@Controller('users')
export class UsersController {
@Get()
@Auth(Permissions.Users.Read)
findAll() {}
@Post()
@Auth(Permissions.Users.Create)
create(@Body() dto: CreateUserDto) {}
@Delete(':id')
@Auth(Permissions.Users.Delete)
remove(@Param('id', ParseUUIDPipe) id: string) {}
}Admin-Only Decorator
export function AdminOnly() {
return applyDecorators(
Roles('admin'),
ApiBearerAuth(),
ApiForbiddenResponse({ description: 'Admin access required' }),
);
}
@Controller('admin')
export class AdminController {
@Get('dashboard')
@AdminOnly()
getDashboard() {}
}Resource Owner Decorator
// common/decorators/owner-or-permission.decorator.ts
export function OwnerOrPermission(permission: string) {
return applyDecorators(
SetMetadata('ownerPermission', permission),
UseGuards(JwtAuthGuard, OwnerOrPermissionGuard),
ApiBearerAuth(),
);
}
// Guard implementation
@Injectable()
export class OwnerOrPermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const permission = this.reflector.get<string>('ownerPermission', context.getHandler());
const request = context.switchToHttp().getRequest();
const user = request.user;
const resourceId = request.params.id ?? request.params.userId;
// Owner check
if (user.id === resourceId) return true;
// Permission check
const permissions: string[] = request.permissions ?? [];
if (permission && permissions.includes(permission)) return true;
throw new ForbiddenException();
}
}
// Usage
@Controller('users')
export class UsersController {
@Get(':id/profile')
@OwnerOrPermission(Permissions.Users.Read)
getProfile(@Param('id', ParseUUIDPipe) id: string) {}
}Rules
- >Use
applyDecorators()to combine auth, Swagger, and permission decorators into one - >Create domain-specific decorators like
@AdminOnly(),@Auth(),@OwnerOrPermission() - >Keep composed decorators in
common/decorators/for reuse across controllers - >Include Swagger decorators in composed decorators — auth requirements auto-documented
- >Prefer composed decorators over stacking 3-4 individual decorators on every route
- >Keep guard logic in guards, not in decorators — decorators only set metadata
11. Fine-Grained Permissions
Implement resource:action permission checks for precise access control beyond simple roles.
Permission Constants
// common/constants/permissions.ts
export const Permissions = {
Users: {
Create: 'users:create',
Read: 'users:read',
Update: 'users:update',
Delete: 'users:delete',
ManageRoles: 'users:manage-roles',
},
Posts: {
Create: 'posts:create',
Read: 'posts:read',
Update: 'posts:update',
UpdateAny: 'posts:update-any',
Delete: 'posts:delete',
DeleteAny: 'posts:delete-any',
Publish: 'posts:publish',
},
Orders: {
Create: 'orders:create',
Read: 'orders:read',
ReadAny: 'orders:read-any',
Update: 'orders:update',
Cancel: 'orders:cancel',
Refund: 'orders:refund',
},
} as const;RequirePermissions Decorator
// common/decorators/permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);Controller Usage
@Controller('posts')
export class PostsController {
@Get()
@RequirePermissions(Permissions.Posts.Read)
findAll(@Query() query: PostQueryDto) {
return this.postsService.findAll(query);
}
@Post()
@RequirePermissions(Permissions.Posts.Create)
create(@Body() dto: CreatePostDto, @CurrentUser() user: AuthUser) {
return this.postsService.create(user.id, dto);
}
@Post(':id/publish')
@RequirePermissions(Permissions.Posts.Publish)
publish(@Param('id', ParseUUIDPipe) id: string) {
return this.postsService.publish(id);
}
@Delete(':id')
@RequirePermissions(Permissions.Posts.Delete, Permissions.Posts.DeleteAny)
// User needs Delete (own) OR DeleteAny — guard checks any match
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.postsService.remove(id, user);
}
}Permission Check in Services
@Injectable()
export class OrdersService {
async findById(orderId: string, currentUser: AuthUser, permissions: string[]) {
const order = await this.ordersRepo.findById(orderId);
if (!order) throw new NotFoundException();
// Can read any order with read-any permission
if (permissions.includes(Permissions.Orders.ReadAny)) {
return order;
}
// Otherwise, can only read own orders
if (order.userId !== currentUser.id) {
throw new ForbiddenException();
}
return order;
}
}Rules
- >Use
resource:actionformat for all permissions — consistent and grep-able - >Define permissions as typed constants — avoid magic strings
- >Distinguish between "own resource" and "any resource" actions:
posts:updatevsposts:update-any - >Guard-level checks handle route access, service-level checks handle data-level access
- >Multiple permissions in
@RequirePermissions()means OR — user needs any one - >Keep permissions flat — don't create nested hierarchies beyond
resource:action
12. Resource Ownership Checks
Verify that users can only access or modify resources they own, unless they have elevated permissions.
Ownership Service
// modules/auth/ownership.service.ts
@Injectable()
export class OwnershipService {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
// Map of resource type to table and owner column
private readonly resourceMap = {
post: { table: posts, ownerColumn: posts.authorId },
comment: { table: comments, ownerColumn: comments.authorId },
order: { table: orders, ownerColumn: orders.userId },
} as const;
async isOwner(
resourceType: keyof typeof this.resourceMap,
resourceId: string,
userId: string,
): Promise<boolean> {
const config = this.resourceMap[resourceType];
if (!config) return false;
const [record] = await this.db
.select({ ownerId: config.ownerColumn })
.from(config.table)
.where(eq((config.table as any).id, resourceId));
return record?.ownerId === userId;
}
}Ownership Guard
// common/guards/resource-owner.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
export interface ResourceOwnerOptions {
resourceType: string;
idParam?: string; // route param name, default 'id'
adminBypass?: boolean; // admins skip ownership check
bypassPermission?: string; // specific permission to bypass
}
export const RESOURCE_OWNER_KEY = 'resourceOwner';
export const CheckOwnership = (options: ResourceOwnerOptions) =>
SetMetadata(RESOURCE_OWNER_KEY, options);
@Injectable()
export class ResourceOwnerGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly ownershipService: OwnershipService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const options = this.reflector.get<ResourceOwnerOptions>(
RESOURCE_OWNER_KEY,
context.getHandler(),
);
if (!options) return true;
const request = context.switchToHttp().getRequest();
const user = request.user;
const resourceId = request.params[options.idParam ?? 'id'];
// Admin bypass
if (options.adminBypass && user.role === 'admin') {
return true;
}
// Permission bypass
if (options.bypassPermission) {
const permissions: string[] = request.permissions ?? [];
if (permissions.includes(options.bypassPermission)) return true;
}
// Ownership check
const isOwner = await this.ownershipService.isOwner(
options.resourceType as any,
resourceId,
user.id,
);
if (!isOwner) {
throw new ForbiddenException('You do not own this resource');
}
return true;
}
}Usage
@Controller('posts')
export class PostsController {
@Patch(':id')
@CheckOwnership({
resourceType: 'post',
adminBypass: true,
bypassPermission: Permissions.Posts.UpdateAny,
})
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePostDto,
) {
return this.postsService.update(id, dto);
}
@Delete(':id')
@CheckOwnership({
resourceType: 'post',
bypassPermission: Permissions.Posts.DeleteAny,
})
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.postsService.remove(id);
}
}Rules
- >Ownership checks happen in guards — before the service layer processes the request
- >Always provide bypass options for admins or users with elevated permissions
- >Use a centralized
OwnershipServiceto avoid duplicating ownership queries - >Map resource types to their tables and owner columns in one place
- >Combine with
@RequirePermissions()for layered authorization: must be authenticated + authorized + owner - >For complex ownership (e.g., team members can edit team posts), use the service layer instead of guards
13. Token Revocation and Blacklisting
Revoke access and refresh tokens for logout, password change, and security incidents.
Access Token Blacklist (Redis)
// modules/auth/token-blacklist.service.ts
@Injectable()
export class TokenBlacklistService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async blacklist(jti: string, expiresInMs: number) {
// Store until the token would naturally expire
await this.cache.set(`blacklist:${jti}`, true, expiresInMs);
}
async isBlacklisted(jti: string): Promise<boolean> {
const result = await this.cache.get(`blacklist:${jti}`);
return result === true;
}
}JWT with JTI (Token ID)
// Include jti in token payload for individual revocation
async generateAccessToken(userId: string, email: string, role: string) {
const jti = randomUUID();
return this.jwtService.signAsync({
sub: userId,
email,
role,
jti,
});
}
// JWT Strategy validates blacklist
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly blacklist: TokenBlacklistService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get('auth.jwtSecret'),
passReqToCallback: true,
});
}
async validate(req: Request, payload: JwtPayload) {
if (payload.jti && await this.blacklist.isBlacklisted(payload.jti)) {
throw new UnauthorizedException('Token has been revoked');
}
return { id: payload.sub, email: payload.email, role: payload.role };
}
}Logout and Revocation Flows
@Injectable()
export class AuthService {
// Single device logout
async logout(accessToken: string, refreshToken: string) {
// Decode access token to get jti and expiry
const decoded = this.jwtService.decode(accessToken) as JwtPayload;
if (decoded?.jti && decoded?.exp) {
const expiresInMs = decoded.exp * 1000 - Date.now();
if (expiresInMs > 0) {
await this.blacklistService.blacklist(decoded.jti, expiresInMs);
}
}
// Revoke refresh token
if (refreshToken) {
await this.refreshTokenService.revokeToken(refreshToken);
}
}
// All devices logout — revoke all refresh tokens
async logoutAllDevices(userId: string) {
await this.db
.update(refreshTokens)
.set({ isRevoked: true })
.where(eq(refreshTokens.userId, userId));
}
// On password change — revoke everything
async onPasswordChange(userId: string) {
await this.logoutAllDevices(userId);
// Note: access tokens will expire naturally (15m max)
// For immediate revocation, blacklist all active JTIs
}
}Cleanup Job
// modules/auth/tasks/cleanup-tokens.task.ts
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class TokenCleanupTask {
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async cleanExpiredTokens() {
const deleted = await this.db
.delete(refreshTokens)
.where(lt(refreshTokens.expiresAt, new Date()))
.returning({ id: refreshTokens.id });
this.logger.log(`Cleaned up ${deleted.length} expired refresh tokens`);
}
}Rules
- >Blacklist access tokens in Redis — TTL matches remaining token lifetime (auto-cleanup)
- >Include
jti(JWT ID) in access tokens for individual revocation capability - >Revoke all refresh tokens on password change or security incident
- >"Logout all devices" = revoke all refresh tokens for the user
- >Access tokens can't be immediately revoked without a blacklist check on every request
- >Schedule cleanup jobs to remove expired refresh tokens from the database
14. Refresh Token Rotation
Rotate refresh tokens on each use to limit the window of compromise.
Token Family Schema
// shared/database/schema/refresh-tokens.schema.ts
import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull().unique(),
family: uuid('family').notNull(), // group tokens from same login session
isRevoked: boolean('is_revoked').notNull().default(false),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});Token Rotation Service
// modules/auth/refresh-token.service.ts
import { randomBytes, createHash } from 'crypto';
@Injectable()
export class RefreshTokenService {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
async createToken(userId: string, family?: string): Promise<string> {
const token = randomBytes(64).toString('base64url');
const tokenHash = this.hashToken(token);
await this.db.insert(refreshTokens).values({
userId,
tokenHash,
family: family ?? randomUUID(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
return token;
}
async rotateToken(oldToken: string): Promise<{ accessToken: string; refreshToken: string }> {
const oldHash = this.hashToken(oldToken);
return this.db.transaction(async (tx) => {
// Find the existing token
const [existing] = await tx
.select()
.from(refreshTokens)
.where(eq(refreshTokens.tokenHash, oldHash));
if (!existing) {
throw new UnauthorizedException('Invalid refresh token');
}
if (existing.isRevoked) {
// Token reuse detected! Revoke entire family
await tx
.update(refreshTokens)
.set({ isRevoked: true })
.where(eq(refreshTokens.family, existing.family));
throw new UnauthorizedException('Token reuse detected — all sessions revoked');
}
if (existing.expiresAt < new Date()) {
throw new UnauthorizedException('Refresh token expired');
}
// Revoke the old token
await tx
.update(refreshTokens)
.set({ isRevoked: true })
.where(eq(refreshTokens.id, existing.id));
// Issue new tokens in the same family
const newRefreshToken = randomBytes(64).toString('base64url');
await tx.insert(refreshTokens).values({
userId: existing.userId,
tokenHash: this.hashToken(newRefreshToken),
family: existing.family,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
// Generate new access token
const user = await tx.query.users.findFirst({
where: eq(users.id, existing.userId),
});
const accessToken = await this.jwtService.signAsync({
sub: user.id,
email: user.email,
role: user.role,
});
return { accessToken, refreshToken: newRefreshToken };
});
}
}Rules
- >Store only the hash of refresh tokens in the database — never store tokens in plain text
- >Use token families to group tokens from the same login session
- >On rotation: revoke old token, issue new token in the same family
- >On reuse of a revoked token: revoke the entire family (indicates theft)
- >Set expiration on refresh tokens — 7-30 days depending on security requirements
- >Clean up expired tokens with a scheduled job to prevent table bloat
15. Passport Session Serialization
Configure how Passport stores and retrieves user data from sessions.
Serializer
// modules/auth/session.serializer.ts
import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { UsersService } from '../users/users.service';
@Injectable()
export class SessionSerializer extends PassportSerializer {
constructor(private readonly usersService: UsersService) {
super();
}
// Called after login — store minimal data in session
serializeUser(user: any, done: (err: Error | null, id?: string) => void) {
done(null, user.id); // only store user ID in session
}
// Called on every request with a session — reconstruct user
async deserializeUser(userId: string, done: (err: Error | null, user?: any) => void) {
try {
const user = await this.usersService.findById(userId);
if (!user) {
return done(null, null);
}
// Return only what downstream code needs
done(null, {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
});
} catch (err) {
done(err as Error);
}
}
}Auth Module Registration
// modules/auth/auth.module.ts
@Module({
imports: [PassportModule.register({ session: true })],
providers: [
AuthService,
LocalStrategy,
SessionSerializer,
],
})
export class AuthModule {}Cached Deserialization
@Injectable()
export class SessionSerializer extends PassportSerializer {
constructor(
private readonly usersService: UsersService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {
super();
}
serializeUser(user: any, done: (err: Error | null, id?: string) => void) {
done(null, user.id);
}
async deserializeUser(userId: string, done: (err: Error | null, user?: any) => void) {
try {
const cacheKey = `session:user:${userId}`;
let user = await this.cache.get(cacheKey);
if (!user) {
user = await this.usersService.findById(userId);
if (user) {
await this.cache.set(cacheKey, user, 60_000); // 1 min cache
}
}
done(null, user ?? null);
} catch (err) {
done(err as Error);
}
}
}Rules
- >Serialize only the user ID to the session — keep session data minimal
- >Deserialize on each request to get fresh user data (roles may have changed)
- >Cache deserialized users to avoid a DB query on every request
- >Invalidate the session cache when user roles or permissions change
- >Register
PassportModule.register({ session: true })when using session auth - >Return
nullfromdeserializeUserif user not found — Passport treats this as unauthenticated
16. Session Authentication Setup
Configure server-side session authentication with Redis store for traditional web applications.
Setup
// main.ts
import * as session from 'express-session';
import * as passport from 'passport';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
// Redis client
const redisClient = createClient({
url: config.get('redis.url'),
});
await redisClient.connect();
// Session middleware
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: config.get('session.secret'),
resave: false,
saveUninitialized: false,
rolling: true, // refresh expiry on activity
cookie: {
httpOnly: true,
secure: config.get('app.isProd'),
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}),
);
// Passport session
app.use(passport.initialize());
app.use(passport.session());
await app.listen(3000);
}Local Strategy
// modules/auth/strategies/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({ usernameField: 'email' });
}
async validate(email: string, password: string) {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
return user; // this becomes req.user and gets serialized to session
}
}Session Guard
// common/guards/session-auth.guard.ts
@Injectable()
export class SessionAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.isAuthenticated();
}
}
// Login guard triggers Passport local strategy
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}Controller
@Controller('auth')
export class AuthController {
@Post('login')
@UseGuards(LocalAuthGuard)
@HttpCode(HttpStatus.OK)
login(@Req() req: Request) {
return { user: req.user, message: 'Login successful' };
}
@Post('logout')
@UseGuards(SessionAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
logout(@Req() req: Request) {
req.logout((err) => {
if (err) throw err;
});
}
@Get('me')
@UseGuards(SessionAuthGuard)
getProfile(@Req() req: Request) {
return req.user;
}
}Rules
- >Use Redis for session storage in production — in-memory sessions don't survive restarts
- >Set
httpOnly: true,secure: true,sameSite: 'strict'on session cookies - >Set
rolling: trueto refresh session expiry on every request (activity-based expiry) - >Use
saveUninitialized: falseto avoid creating sessions for unauthenticated requests - >
req.isAuthenticated()checks if a session exists — use in session-based guards - >Session auth is better for traditional server-rendered apps; JWT is better for SPAs and APIs
17. GitHub OAuth2 Integration
Implement GitHub sign-in for developer-facing applications.
GitHub Strategy
// modules/auth/strategies/github.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, Profile } from 'passport-github2';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class GitHubStrategy extends PassportStrategy(Strategy, 'github') {
constructor(config: ConfigService) {
super({
clientID: config.get('oauth.github.clientId'),
clientSecret: config.get('oauth.github.clientSecret'),
callbackURL: config.get('oauth.github.callbackUrl'),
scope: ['user:email'],
});
}
async validate(accessToken: string, refreshToken: string, profile: Profile) {
const email = profile.emails?.[0]?.value;
return {
providerId: profile.id,
provider: 'github',
email,
name: profile.displayName || profile.username,
avatarUrl: profile.photos?.[0]?.value,
username: profile.username,
};
}
}Controller Routes
@Controller('auth')
export class AuthController {
@Get('github')
@UseGuards(AuthGuard('github'))
@Public()
githubLogin() {}
@Get('github/callback')
@UseGuards(AuthGuard('github'))
@Public()
async githubCallback(@Req() req: Request, @Res() res: Response) {
const tokens = await this.authService.handleOAuthLogin(req.user);
// Set cookies for browser-based flow
this.setTokenCookies(res, tokens);
res.redirect(this.config.get('app.frontendUrl'));
}
}OAuth Config
// config/oauth.config.ts
import { registerAs } from '@nestjs/config';
import { z } from 'zod';
const schema = z.object({
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GOOGLE_CALLBACK_URL: z.string().url().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GITHUB_CALLBACK_URL: z.string().url().optional(),
});
export const oauthConfig = registerAs('oauth', () => {
const env = schema.parse(process.env);
return {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
callbackUrl: env.GOOGLE_CALLBACK_URL,
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
callbackUrl: env.GITHUB_CALLBACK_URL,
},
};
});Conditional Strategy Registration
// modules/auth/auth.module.ts
const strategies = [LocalStrategy, JwtStrategy];
// Only register OAuth strategies if configured
if (process.env.GOOGLE_CLIENT_ID) {
strategies.push(GoogleStrategy);
}
if (process.env.GITHUB_CLIENT_ID) {
strategies.push(GitHubStrategy);
}
@Module({
providers: [...strategies, AuthService],
})
export class AuthModule {}Rules
- >Request
user:emailscope for GitHub — email may not be in the default profile - >Handle missing email gracefully — some GitHub users have private emails
- >Conditionally register OAuth strategies — don't fail if credentials aren't configured
- >Use the same
handleOAuthLoginservice method for all providers — DRY - >Store the GitHub username in the user profile for display purposes
- >Validate OAuth config with Zod but make all fields optional — not all providers may be enabled
18. Google OAuth2 Integration
Implement Google sign-in with Passport OAuth2 strategy.
Setup
pnpm add passport-google-oauth20 @nestjs/passport
pnpm add -D @types/passport-google-oauth20Google Strategy
// modules/auth/strategies/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback, Profile } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(config: ConfigService) {
super({
clientID: config.get('oauth.google.clientId'),
clientSecret: config.get('oauth.google.clientSecret'),
callbackURL: config.get('oauth.google.callbackUrl'),
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
) {
const { emails, displayName, photos, id } = profile;
const user = {
providerId: id,
provider: 'google',
email: emails?.[0]?.value,
name: displayName,
avatarUrl: photos?.[0]?.value,
};
done(null, user);
}
}OAuth Controller
// modules/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
@Get('google')
@UseGuards(AuthGuard('google'))
@Public()
googleLogin() {
// Redirects to Google consent screen
}
@Get('google/callback')
@UseGuards(AuthGuard('google'))
@Public()
async googleCallback(@Req() req: Request, @Res() res: Response) {
// req.user contains the profile from GoogleStrategy.validate()
const tokens = await this.authService.handleOAuthLogin(req.user);
// Redirect to frontend with tokens
const params = new URLSearchParams({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
res.redirect(`${this.config.get('app.frontendUrl')}/auth/callback?${params}`);
}
}OAuth User Service
// modules/auth/auth.service.ts
async handleOAuthLogin(profile: OAuthProfile): Promise<AuthTokens> {
// Check if user exists with this OAuth provider
let user = await this.usersService.findByProvider(profile.provider, profile.providerId);
if (!user) {
// Check if email exists (link account)
const existingUser = await this.usersService.findByEmail(profile.email);
if (existingUser) {
// Link OAuth to existing account
await this.usersService.linkProvider(existingUser.id, {
provider: profile.provider,
providerId: profile.providerId,
});
user = existingUser;
} else {
// Create new user
user = await this.usersService.createFromOAuth(profile);
}
}
return this.generateTokens(user.id, user.email, user.role);
}OAuth Providers Schema
// shared/database/schema/oauth-accounts.schema.ts
export const oauthAccounts = pgTable(
'oauth_accounts',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
provider: varchar('provider', { length: 50 }).notNull(), // 'google', 'github'
providerId: varchar('provider_id', { length: 255 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('oauth_provider_id_idx').on(table.provider, table.providerId),
],
);Rules
- >Store OAuth provider data in a separate
oauth_accountstable — users can have multiple providers - >Always check for existing email before creating a new user — prevents duplicate accounts
- >Redirect to frontend after OAuth callback with tokens as query params or set cookies
- >Never expose OAuth client secrets — store in environment variables
- >Request minimal scopes (
email,profile) — only ask for what you need - >Handle the case where Google email matches an existing account (auto-link)
19. Account Linking
Allow users to connect multiple OAuth providers to a single account.
Linking Service
// modules/auth/account-linking.service.ts
@Injectable()
export class AccountLinkingService {
constructor(
@Inject(DRIZZLE) private readonly db: DrizzleDB,
) {}
async linkProvider(userId: string, provider: string, providerId: string) {
// Check if this provider account is already linked to someone
const existing = await this.db.query.oauthAccounts.findFirst({
where: and(
eq(oauthAccounts.provider, provider),
eq(oauthAccounts.providerId, providerId),
),
});
if (existing && existing.userId !== userId) {
throw new ConflictException(
`This ${provider} account is already linked to another user`,
);
}
if (existing && existing.userId === userId) {
return; // Already linked
}
await this.db.insert(oauthAccounts).values({
userId,
provider,
providerId,
});
}
async unlinkProvider(userId: string, provider: string) {
// Ensure user has at least one auth method remaining
const userAccounts = await this.db.query.oauthAccounts.findMany({
where: eq(oauthAccounts.userId, userId),
});
const user = await this.db.query.users.findFirst({
where: eq(users.id, userId),
});
const hasPassword = !!user?.passwordHash;
const otherProviders = userAccounts.filter((a) => a.provider !== provider);
if (!hasPassword && otherProviders.length === 0) {
throw new BadRequestException(
'Cannot unlink — set a password or link another provider first',
);
}
await this.db
.delete(oauthAccounts)
.where(and(
eq(oauthAccounts.userId, userId),
eq(oauthAccounts.provider, provider),
));
}
async getLinkedProviders(userId: string) {
const accounts = await this.db.query.oauthAccounts.findMany({
where: eq(oauthAccounts.userId, userId),
columns: { provider: true, createdAt: true },
});
return accounts;
}
}Controller
@Controller('auth/providers')
export class ProvidersController {
@Get()
getLinkedProviders(@CurrentUser('id') userId: string) {
return this.linkingService.getLinkedProviders(userId);
}
// Initiate linking flow (user must be authenticated)
@Get('google/link')
@UseGuards(AuthGuard('google'))
linkGoogle() {}
@Get('google/link/callback')
@UseGuards(AuthGuard('google'))
async googleLinkCallback(@CurrentUser('id') userId: string, @Req() req: Request) {
await this.linkingService.linkProvider(userId, 'google', req.user.providerId);
return { message: 'Google account linked' };
}
@Delete(':provider')
unlinkProvider(
@CurrentUser('id') userId: string,
@Param('provider') provider: string,
) {
return this.linkingService.unlinkProvider(userId, provider);
}
}Rules
- >Prevent linking a provider account that's already linked to a different user
- >Prevent unlinking the last auth method — user must have a password or another provider
- >Linking requires an authenticated session — user initiates it from their settings
- >Keep the OAuth accounts table separate from users — clean separation of concerns
- >Return linked providers in the user profile API for the frontend settings UI
- >Handle the edge case where a user tries to link the same provider twice (idempotent)
20. Tenant Identification
Identify the current tenant from subdomain, header, or JWT claim in multi-tenant applications.
Tenant Middleware
// common/middleware/tenant.middleware.ts
import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = this.resolveTenant(req);
if (!tenantId) {
throw new BadRequestException('Tenant identification required');
}
// Attach to request for downstream use
(req as any).tenantId = tenantId;
next();
}
private resolveTenant(req: Request): string | null {
// Strategy 1: Custom header
const headerTenant = req.headers['x-tenant-id'] as string;
if (headerTenant) return headerTenant;
// Strategy 2: Subdomain
const host = req.hostname;
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
return subdomain;
}
// Strategy 3: JWT claim (if authenticated)
const user = (req as any).user;
if (user?.tenantId) return user.tenantId;
return null;
}
}Tenant Decorator
// common/decorators/tenant.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentTenant = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.tenantId;
},
);Tenant Guard
// common/guards/tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const tenantId = request.tenantId;
if (!tenantId) {
throw new BadRequestException('Tenant not identified');
}
// Verify tenant exists and is active
const tenant = await this.db.query.tenants.findFirst({
where: and(eq(tenants.id, tenantId), eq(tenants.isActive, true)),
});
if (!tenant) {
throw new NotFoundException('Tenant not found or inactive');
}
request.tenant = tenant;
return true;
}
}Usage
@Controller('projects')
export class ProjectsController {
@Get()
findAll(@CurrentTenant() tenantId: string) {
return this.projectsService.findByTenant(tenantId);
}
@Post()
create(
@CurrentTenant() tenantId: string,
@CurrentUser() user: AuthUser,
@Body() dto: CreateProjectDto,
) {
return this.projectsService.create(tenantId, user.id, dto);
}
}Rules
- >Resolve tenant as early as possible — middleware runs before guards and interceptors
- >Support multiple identification strategies (header, subdomain, JWT) with fallback chain
- >Validate that the tenant exists and is active before processing the request
- >Attach
tenantIdto the request object for use throughout the request lifecycle - >Create a
@CurrentTenant()decorator for clean access in controllers - >Fail loudly if tenant can't be identified — never default to a tenant silently
21. Tenant Data Isolation
Ensure tenants can only access their own data using row-level filtering or schema-level separation.
Row-Level Security (Application Layer)
// shared/database/base-tenant.repository.ts
@Injectable()
export abstract class BaseTenantRepository<TTable extends PgTable> {
constructor(
@Inject(DRIZZLE) protected readonly db: DrizzleDB,
@Inject(REQUEST) protected readonly request: Request,
protected readonly table: TTable,
) {}
private get tenantId(): string {
return (this.request as any).tenantId;
}
async findAll(where?: SQL): Promise<any[]> {
const tenantCondition = eq((this.table as any).tenantId, this.tenantId);
const fullWhere = where ? and(tenantCondition, where) : tenantCondition;
return this.db.select().from(this.table).where(fullWhere);
}
async findById(id: string): Promise<any | null> {
const [record] = await this.db
.select()
.from(this.table)
.where(and(
eq((this.table as any).id, id),
eq((this.table as any).tenantId, this.tenantId),
));
return record ?? null;
}
async create(data: any): Promise<any> {
const [record] = await this.db
.insert(this.table)
.values({ ...data, tenantId: this.tenantId })
.returning();
return record;
}
async update(id: string, data: any): Promise<any | null> {
const [record] = await this.db
.update(this.table)
.set(data)
.where(and(
eq((this.table as any).id, id),
eq((this.table as any).tenantId, this.tenantId),
))
.returning();
return record ?? null;
}
async delete(id: string): Promise<void> {
await this.db
.delete(this.table)
.where(and(
eq((this.table as any).id, id),
eq((this.table as any).tenantId, this.tenantId),
));
}
}Tenant-Scoped Repository Usage
@Injectable({ scope: Scope.REQUEST })
export class ProjectsRepository extends BaseTenantRepository<typeof projects> {
constructor(
@Inject(DRIZZLE) db: DrizzleDB,
@Inject(REQUEST) request: Request,
) {
super(db, request, projects);
}
async findByStatus(status: string) {
return this.findAll(eq(projects.status, status));
// Automatically scoped to current tenant
}
}PostgreSQL Row-Level Security (Database Layer)
-- Migration: enable RLS on tenant tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Force RLS even for table owners
ALTER TABLE projects FORCE ROW LEVEL SECURITY;// Set tenant context before each query
@Injectable()
export class TenantDatabaseInterceptor implements NestInterceptor {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const tenantId = request.tenantId;
if (tenantId) {
await this.db.execute(
sql`SET LOCAL app.current_tenant_id = ${tenantId}`,
);
}
return next.handle();
}
}Rules
- >Application-layer filtering: every query must include
WHERE tenant_id = ?— use a base repository - >Database-layer RLS: PostgreSQL enforces isolation regardless of application bugs (defense in depth)
- >Use
Scope.REQUESTfor tenant repositories — they need the current request's tenant ID - >Always inject
tenantIdautomatically — never rely on developers remembering to filter - >Test isolation: verify that Tenant A cannot access Tenant B's data
- >Add
tenantIdcolumn with an index on every tenant-scoped table
22. Brute Force Protection
Protect login endpoints from brute force attacks with progressive delays and account lockout.
Login Attempt Tracking
// modules/auth/login-attempts.service.ts
@Injectable()
export class LoginAttemptsService {
private readonly MAX_ATTEMPTS = 5;
private readonly LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
private readonly ATTEMPT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
private key(identifier: string) {
return `login_attempts:${identifier}`;
}
async recordFailedAttempt(identifier: string): Promise<{
attemptsRemaining: number;
lockedUntil: Date | null;
}> {
const key = this.key(identifier);
const data = await this.cache.get<{ count: number; firstAttempt: number }>(key);
const now = Date.now();
const count = data ? data.count + 1 : 1;
const firstAttempt = data?.firstAttempt ?? now;
await this.cache.set(key, { count, firstAttempt }, this.ATTEMPT_WINDOW_MS);
if (count >= this.MAX_ATTEMPTS) {
const lockKey = `lockout:${identifier}`;
await this.cache.set(lockKey, true, this.LOCKOUT_DURATION_MS);
return {
attemptsRemaining: 0,
lockedUntil: new Date(now + this.LOCKOUT_DURATION_MS),
};
}
return {
attemptsRemaining: this.MAX_ATTEMPTS - count,
lockedUntil: null,
};
}
async isLocked(identifier: string): Promise<boolean> {
return (await this.cache.get(`lockout:${identifier}`)) === true;
}
async resetAttempts(identifier: string) {
await this.cache.del(this.key(identifier));
await this.cache.del(`lockout:${identifier}`);
}
}Integration with Auth Service
@Injectable()
export class AuthService {
async login(email: string, password: string, ip: string): Promise<AuthTokens> {
// Check lockout by both email and IP
const isEmailLocked = await this.loginAttempts.isLocked(email);
const isIpLocked = await this.loginAttempts.isLocked(ip);
if (isEmailLocked || isIpLocked) {
throw new HttpException(
'Account temporarily locked. Try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
const user = await this.usersService.findByEmail(email);
if (!user || !(await verifyPassword(user.passwordHash, password))) {
// Record failed attempt for both email and IP
await this.loginAttempts.recordFailedAttempt(email);
await this.loginAttempts.recordFailedAttempt(ip);
throw new UnauthorizedException('Invalid credentials');
}
// Reset attempts on successful login
await this.loginAttempts.resetAttempts(email);
await this.loginAttempts.resetAttempts(ip);
return this.generateTokens(user.id, user.email, user.role);
}
}Rules
- >Track attempts by both email AND IP — prevents targeting a single account or rotating accounts
- >Lock accounts after 5 failed attempts for 15 minutes — progressive delays
- >Store attempt counts in Redis/cache with TTL — auto-cleanup, no database bloat
- >Reset attempts on successful login — don't penalize legitimate users
- >Return generic error messages — don't reveal whether the email exists
- >Log lockout events for security monitoring and incident response
23. CSRF and CORS Configuration
Configure CORS and CSRF protection for secure cross-origin communication.
CORS Configuration
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
app.enableCors({
origin: config.get<string[]>('app.corsOrigins'),
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'X-Tenant-Id'],
exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
credentials: true, // required for cookies
maxAge: 86400, // preflight cache: 24 hours
});
await app.listen(3000);
}CSRF Protection (for Cookie-Based Auth)
// modules/auth/csrf.service.ts
import { randomBytes, createHmac } from 'crypto';
@Injectable()
export class CsrfService {
constructor(private readonly config: ConfigService) {}
generateToken(sessionId: string): string {
const secret = this.config.get('csrf.secret');
const salt = randomBytes(16).toString('hex');
const hmac = createHmac('sha256', secret)
.update(`${sessionId}:${salt}`)
.digest('hex');
return `${salt}:${hmac}`;
}
validateToken(token: string, sessionId: string): boolean {
const secret = this.config.get('csrf.secret');
const [salt, hmac] = token.split(':');
if (!salt || !hmac) return false;
const expected = createHmac('sha256', secret)
.update(`${sessionId}:${salt}`)
.digest('hex');
return hmac === expected;
}
}
// CSRF Guard
@Injectable()
export class CsrfGuard implements CanActivate {
constructor(private readonly csrfService: CsrfService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// Skip CSRF for safe methods
if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return true;
// Skip for Bearer token auth (not vulnerable to CSRF)
if (request.headers.authorization?.startsWith('Bearer ')) return true;
const token = request.headers['x-csrf-token'];
const sessionId = request.session?.id;
if (!token || !sessionId) {
throw new ForbiddenException('CSRF token missing');
}
if (!this.csrfService.validateToken(token, sessionId)) {
throw new ForbiddenException('Invalid CSRF token');
}
return true;
}
}Security Headers
// main.ts
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
crossOriginEmbedderPolicy: false, // may break some third-party integrations
}));CSRF Token Endpoint
@Controller('auth')
export class AuthController {
@Get('csrf-token')
getCsrfToken(@Req() req: Request) {
const token = this.csrfService.generateToken(req.session.id);
return { csrfToken: token };
}
}Rules
- >CORS: whitelist specific origins — never use
origin: '*'withcredentials: true - >CORS: set
credentials: trueonly when using cookie-based auth - >CSRF protection is needed for cookie-based auth — not for Bearer token auth (tokens aren't auto-sent)
- >Use
helmetmiddleware for security headers (CSP, X-Frame-Options, HSTS) - >Include
X-CSRF-TokeninallowedHeaderswhen using CSRF protection - >Skip CSRF validation for GET/HEAD/OPTIONS — they should be safe (no side effects)
24. Password Hashing and Validation
Securely hash and validate passwords using argon2.
Password Utility
// common/utils/password.util.ts
import { hash, verify, Options } from 'argon2';
const ARGON2_OPTIONS: Options & { raw?: false } = {
type: 2, // argon2id (recommended)
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
};
export async function hashPassword(password: string): Promise<string> {
return hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await verify(hash, password);
} catch {
return false;
}
}Password Validation DTO
// common/dto/password.dto.ts
import { IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class PasswordDto {
@IsString()
@MinLength(8)
@MaxLength(128)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/, {
message: 'Password must contain uppercase, lowercase, number, and special character',
})
password: string;
}
export class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(8)
@MaxLength(128)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/)
newPassword: string;
}Password Change Service
@Injectable()
export class AuthService {
async changePassword(userId: string, dto: ChangePasswordDto) {
const user = await this.usersRepo.findById(userId);
if (!user) throw new NotFoundException();
const isValid = await verifyPassword(user.passwordHash, dto.currentPassword);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
// Prevent reuse of current password
const isSame = await verifyPassword(user.passwordHash, dto.newPassword);
if (isSame) {
throw new BadRequestException('New password must be different from current');
}
const newHash = await hashPassword(dto.newPassword);
await this.usersRepo.update(userId, { passwordHash: newHash });
// Revoke all sessions/refresh tokens after password change
await this.refreshTokenService.revokeAllForUser(userId);
return { message: 'Password changed successfully' };
}
}Rules
- >Use
argon2id(type 2) — resistant to both GPU and side-channel attacks - >Set memory cost to at least 64MB and time cost to at least 3 iterations
- >Never store passwords in plain text, MD5, SHA-256, or unsalted hashes
- >Cap password length at 128 characters — argon2 is intentionally slow on long inputs
- >Revoke all sessions after password change — prevents stolen tokens from remaining valid
- >Return generic "Invalid credentials" on login failure — don't reveal whether email or password was wrong
25. E2E Testing Protected Endpoints
Test authenticated endpoints with real JWT tokens in E2E tests.
Auth Test Helper
// test/helpers/auth.helper.ts
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
export async function getAuthToken(
app: INestApplication,
email: string = 'admin@test.com',
password: string = 'TestPass123!',
): Promise<string> {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({ email, password })
.expect(200);
return response.body.data.accessToken;
}
export async function registerAndLogin(
app: INestApplication,
user: { email: string; name: string; password: string },
): Promise<{ token: string; userId: string }> {
const registerRes = await request(app.getHttpServer())
.post('/auth/register')
.send(user)
.expect(201);
return {
token: registerRes.body.data.accessToken,
userId: registerRes.body.data.user.id,
};
}
// Helper for making authenticated requests
export function authRequest(app: INestApplication, token: string) {
const server = app.getHttpServer();
return {
get: (url: string) => request(server).get(url).set('Authorization', `Bearer ${token}`),
post: (url: string) => request(server).post(url).set('Authorization', `Bearer ${token}`),
patch: (url: string) => request(server).patch(url).set('Authorization', `Bearer ${token}`),
delete: (url: string) => request(server).delete(url).set('Authorization', `Bearer ${token}`),
};
}E2E Test with Authentication
// test/users/users.e2e-spec.ts
describe('Users (e2e)', () => {
let app: INestApplication;
let adminToken: string;
let memberToken: string;
let memberId: string;
beforeAll(async () => {
app = await createTestApp();
await resetDatabase();
await seedTestUsers();
adminToken = await getAuthToken(app, 'admin@test.com', 'TestPass123!');
const member = await registerAndLogin(app, {
email: 'member@test.com',
name: 'Member',
password: 'TestPass123!',
});
memberToken = member.token;
memberId = member.userId;
});
afterAll(async () => {
await app.close();
});
describe('GET /users', () => {
it('should return users for admin', () => {
return authRequest(app, adminToken)
.get('/users')
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(Array.isArray(res.body.data)).toBe(true);
});
});
it('should return 401 without token', () => {
return request(app.getHttpServer())
.get('/users')
.expect(401);
});
it('should return 403 for member', () => {
return authRequest(app, memberToken)
.get('/users')
.expect(403);
});
});
describe('PATCH /users/:id', () => {
it('should allow user to update own profile', () => {
return authRequest(app, memberToken)
.patch(`/users/${memberId}`)
.send({ name: 'Updated Name' })
.expect(200)
.expect((res) => {
expect(res.body.data.name).toBe('Updated Name');
});
});
it('should prevent member from updating other users', () => {
return authRequest(app, memberToken)
.patch('/users/other-user-id')
.send({ name: 'Hacked' })
.expect(403);
});
it('should allow admin to update any user', () => {
return authRequest(app, adminToken)
.patch(`/users/${memberId}`)
.send({ name: 'Admin Updated' })
.expect(200);
});
});
});Testing Auth Flows
describe('Auth (e2e)', () => {
it('should register, login, and access protected route', async () => {
// Register
const registerRes = await request(app.getHttpServer())
.post('/auth/register')
.send({ email: 'new@test.com', name: 'New', password: 'TestPass123!' })
.expect(201);
const { accessToken } = registerRes.body.data;
// Access protected route
await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200)
.expect((res) => {
expect(res.body.data.email).toBe('new@test.com');
});
});
it('should return 401 for invalid credentials', () => {
return request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'admin@test.com', password: 'wrong' })
.expect(401);
});
it('should refresh tokens', async () => {
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'admin@test.com', password: 'TestPass123!' })
.expect(200);
const { refreshToken } = loginRes.body.data;
await request(app.getHttpServer())
.post('/auth/refresh')
.send({ refreshToken })
.expect(200)
.expect((res) => {
expect(res.body.data.accessToken).toBeDefined();
expect(res.body.data.refreshToken).toBeDefined();
});
});
});Rules
- >Use real token generation in E2E tests — don't mock auth at this level
- >Create helper functions for common auth operations (login, register, authenticated requests)
- >Test all permission levels: unauthenticated (401), unauthorized (403), and authorized (200)
- >Test resource ownership: user can access own resources, can't access others'
- >Test complete auth flows: register → login → access → refresh → logout
- >Seed test users with known credentials in
beforeAll— don't rely on pre-existing data
26. Mocking Auth in Unit Tests
Override auth guards and inject mock users for isolated unit testing.
Override Global Guard
// modules/users/users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { RolesGuard } from '@/common/guards/roles.guard';
describe('UsersController', () => {
let controller: UsersController;
let service: jest.Mocked<UsersService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: {
findAll: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
},
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get(UsersController);
service = module.get(UsersService);
});
it('should return users', async () => {
const mockUsers = [{ id: '1', name: 'Alice', email: 'alice@test.com' }];
service.findAll.mockResolvedValue({ data: mockUsers, meta: { total: 1 } });
const result = await controller.findAll({});
expect(result.data).toEqual(mockUsers);
});
});Mock User Factory
// test/factories/auth.factory.ts
import { AuthUser } from '@/common/decorators/current-user.decorator';
export function buildAuthUser(overrides: Partial<AuthUser> = {}): AuthUser {
return {
id: 'user-123',
email: 'test@example.com',
role: 'member',
...overrides,
};
}
export const mockAdmin = buildAuthUser({ id: 'admin-1', role: 'admin' });
export const mockMember = buildAuthUser({ id: 'member-1', role: 'member' });Testing Service Authorization
describe('PostsService', () => {
it('should allow admin to delete any post', async () => {
const post = { id: 'post-1', authorId: 'other-user', title: 'Test' };
postsRepo.findById.mockResolvedValue(post);
postsRepo.delete.mockResolvedValue(post);
const result = await service.remove('post-1', mockAdmin);
expect(postsRepo.delete).toHaveBeenCalledWith('post-1');
});
it('should prevent member from deleting others post', async () => {
const post = { id: 'post-1', authorId: 'other-user', title: 'Test' };
postsRepo.findById.mockResolvedValue(post);
await expect(service.remove('post-1', mockMember)).rejects.toThrow(ForbiddenException);
});
});Testing Guards Directly
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(() => {
reflector = new Reflector();
guard = new RolesGuard(reflector);
});
it('should allow when no roles required', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
const context = createMockExecutionContext({ user: { role: 'member' } });
expect(guard.canActivate(context)).toBe(true);
});
it('should reject when user lacks required role', () => {
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
const context = createMockExecutionContext({ user: { role: 'member' } });
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
});
});Rules
- >Use
.overrideGuard()to bypass auth in controller unit tests — focus on business logic - >Test authorization logic in services independently with mock users
- >Test guards in isolation with mock execution contexts
- >Create user factories for consistent test data:
mockAdmin,mockMember - >Don't mock auth in E2E tests — use real token generation instead
- >Test both positive (allowed) and negative (forbidden) authorization paths