NestJS Security Skill
When generating or reviewing NestJS security code, apply defense-in-depth: every layer of the application must validate and protect independently. Never rely on a single guard as the only line of defense.
Security Layer Map
Security concerns by layer:
├── Network/Infra → Helmet, CORS, TLS termination, rate limiting
├── Authentication → JWT, refresh tokens, session invalidation
├── Authorization → RBAC/ABAC guards, policy enforcement
├── Input → class-validator, class-transformer, file validation
├── Data → Parameterized queries, field-level encryption
├── Secrets → ConfigService, vault integration, no .env in repo
└── Audit → Structured logging, sensitive-action tracking
1. Bootstrap Hardening
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ["error", "warn"], // never log 'verbose' or 'debug' in production
});
// Helmet — sets secure HTTP headers
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
}),
);
// CORS — explicit allowlist only
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(",") ?? [],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
credentials: true,
});
// Global validation pipe — always whitelist, always transform
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip unknown properties
forbidNonWhitelisted: true, // throw on unknown properties
transform: true, // auto-transform types
transformOptions: { enableImplicitConversion: false },
}),
);
// Global exception filter — never leak stack traces
app.useGlobalFilters(new GlobalExceptionFilter());
await app.listen(3000, "0.0.0.0");
}
2. Authentication — JWT + Refresh Token Rotation
// infrastructure/auth/strategies/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>("JWT_SECRET"), // throws if missing
algorithms: ["HS256"],
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
// Always re-validate user existence and status on each request
const user = await this.userRepo.findById(payload.sub);
if (!user || user.status !== UserStatus.ACTIVE)
throw new UnauthorizedException();
return { id: user.id, roles: user.roles };
}
}
// Refresh token — store hashed, rotate on every use
@Injectable()
export class AuthService {
async refreshTokens(userId: string, rawRefreshToken: string) {
const stored = await this.tokenRepo.findByUserId(userId);
if (!stored) throw new ForbiddenException();
const isValid = await bcrypt.compare(rawRefreshToken, stored.hashedToken);
if (!isValid) {
// Possible token reuse attack — invalidate ALL tokens for this user
await this.tokenRepo.deleteAllForUser(userId);
throw new ForbiddenException("Refresh token reuse detected");
}
// Rotate: delete old, issue new
await this.tokenRepo.delete(stored.id);
return this.issueTokens(userId);
}
private async issueTokens(userId: string) {
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync({ sub: userId }, { expiresIn: "15m" }),
this.jwt.signAsync(
{ sub: userId },
{
expiresIn: "7d",
secret: this.config.getOrThrow("JWT_REFRESH_SECRET"),
},
),
]);
const hashed = await bcrypt.hash(refreshToken, 12);
await this.tokenRepo.save({ userId, hashedToken: hashed });
return { accessToken, refreshToken };
}
}
3. Authorization — RBAC with Policy Guards
// shared/decorators/roles.decorator.ts
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// infrastructure/http/guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles?.length) return true;
const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
return requiredRoles.some(role => user?.roles?.includes(role));
}
}
// Usage in controller
@Roles(Role.ADMIN)
@UseGuards(JwtAuthGuard, RolesGuard)
@Delete(':id')
async deleteUser(@Param('id') id: string) { ... }
4. Input Validation & Sanitization
// application/dtos/create-article.dto.ts
import { Transform } from "class-transformer";
import { IsString, MaxLength, Matches } from "class-validator";
import sanitizeHtml from "sanitize-html";
export class CreateArticleDto {
@IsString()
@MaxLength(200)
@Matches(/^[\w\s\-.,!?]+$/, { message: "Title contains invalid characters" })
title: string;
@IsString()
@MaxLength(50000)
@Transform(({ value }) =>
sanitizeHtml(value, {
allowedTags: ["b", "i", "em", "strong", "p", "ul", "ol", "li"],
allowedAttributes: {}, // no attributes at all
}),
)
content: string;
// Never trust client-supplied IDs for ownership — derive from JWT instead
// authorId comes from @CurrentUser() decorator, NOT from body
}
File Upload Validation
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5MB
new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }),
],
}),
) file: Express.Multer.File) {
// Rename with uuid — never use original filename
const safeName = `${randomUUID()}.${mime.extension(file.mimetype)}`;
return this.uploadService.save(file.buffer, safeName);
}
5. Rate Limiting
// app.module.ts
ThrottlerModule.forRootAsync({
useFactory: (config: ConfigService) => [{
name: 'default',
ttl: 60_000,
limit: 100,
}, {
name: 'auth', // stricter limit for auth endpoints
ttl: 60_000,
limit: 10,
}],
inject: [ConfigService],
}),
// On the auth controller
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
@Post('login')
async login(@Body() dto: LoginDto) { ... }
6. Secrets Management
// infrastructure/config/config.schema.ts — validate all env vars at startup
import * as Joi from "joi";
export const configSchema = Joi.object({
NODE_ENV: Joi.string().valid("development", "production", "test").required(),
DB_PASSWORD: Joi.string().min(24).required(),
JWT_SECRET: Joi.string().min(64).required(),
JWT_REFRESH_SECRET: Joi.string().min(64).required(),
ENCRYPTION_KEY: Joi.string().length(64).required(), // 32 bytes hex
});
// app.module.ts
ConfigModule.forRoot({
validationSchema: configSchema,
validationOptions: { abortEarly: false },
isGlobal: true,
});
Rules:
- Never hardcode secrets or use
process.env.Xdirectly — always useConfigService.getOrThrow() - Rotate secrets without downtime using dual-secret validation during rotation window
- Use AWS Secrets Manager / HashiCorp Vault in production;
.envonly for local dev and must be in.gitignore
7. Audit Logging
// infrastructure/http/interceptors/audit.interceptor.ts
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly logger: Logger) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const sensitive = Reflect.getMetadata(AUDIT_KEY, context.getHandler());
if (!sensitive) return next.handle();
const start = Date.now();
return next.handle().pipe(
tap({
next: () => this.log(req, 'SUCCESS', Date.now() - start),
error: (err) => this.log(req, 'FAILURE', Date.now() - start, err),
}),
);
}
private log(req: AuthenticatedRequest, outcome: string, ms: number, err?: unknown) {
this.logger.log({
type: 'AUDIT',
userId: req.user?.id,
ip: req.ip,
method: req.method,
path: req.path,
outcome,
durationMs: ms,
error: err instanceof Error ? err.message : undefined,
// NEVER log req.body — may contain passwords, PII
});
}
}
// Usage
@Audit() // custom decorator that sets AUDIT_KEY metadata
@Post('transfer')
async transfer(@Body() dto: TransferDto) { ... }
8. Global Exception Filter — No Stack Trace Leakage
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
const isHttpException = exception instanceof HttpException;
const status = isHttpException ? exception.getStatus() : 500;
// Log full error internally
this.logger.error({ exception, path: req.path });
// Return sanitized response — never expose internals
res.status(status).json({
statusCode: status,
message: isHttpException ? exception.message : "Internal server error",
timestamp: new Date().toISOString(),
path: req.path,
// No stack, no internal error details
});
}
}
Security Review Checklist
When auditing NestJS code for security:
- Auth on every route — Are all non-public routes protected by
JwtAuthGuard? Is there a@Public()decorator for intentional exceptions, and is it documented? - Input validation — Does every DTO use
class-validator? Iswhitelist: trueset globally? Are file uploads type-checked and size-limited? - No raw queries — Are all DB queries using parameterized ORM methods? Search for raw SQL strings and template literals with user input.
- Secret hygiene — Is
ConfigService.getOrThrow()used everywhere? Search forprocess.envdirect access. - Refresh token rotation — Are refresh tokens hashed at rest and rotated on each use? Is token reuse detected and handled by invalidating the session?
- Rate limiting — Are auth endpoints under stricter throttle limits than general API?
- Error responses — Do error responses ever include stack traces, DB errors, or internal paths?
- CORS allowlist — Is CORS configured with an explicit origin list, not
origin: true? - Audit trail — Are sensitive actions (payment, role change, data export, admin actions) decorated with
@Audit()? - Dependency scan — Is
npm auditpart of the CI pipeline? Are known-vulnerable packages patched?
How to provide feedback
- Cite the specific OWASP category or CWE the issue falls under
- Show the vulnerable code path end-to-end (route → guard → service)
- Provide a corrected snippet, not just a description
- Note the severity: Critical (data breach risk), High (auth bypass), Medium (info leakage), Low (hardening improvement)