NestJS course · Module 9: Deployment and Infrastructure
Security Best Practices - protection against barbarians
In this lesson6
Guardian of the tributes! On the Empire's frontiers barbarians rarely storm the walls. They try keys: passwords stolen from other services, forged tokens, secrets someone left in a repository. Consul Caesar.js wants you to know the best methods of defending against such attacks.
Authentication & Authorization
Authentication checks who the legionary is, and authorization what he is allowed to do. A Passport strategy verifies the JWT token:
1// JWT Strategy
2import { Injectable } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { PassportStrategy } from '@nestjs/passport';
5import { ExtractJwt, Strategy } from 'passport-jwt';
6
7@Injectable()
8export class JwtStrategy extends PassportStrategy(Strategy) {
9 constructor(config: ConfigService) {
10 super({
11 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
12 ignoreExpiration: false,
13 secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
14 algorithms: ['HS256'], // accept only the algorithm you sign with
15 });
16 }
17
18 async validate(payload: any) {
19 return { userId: payload.sub, username: payload.username };
20 }
21}ExtractJwt.fromAuthHeaderAsBearerToken() reads the token from the Authorization header, and ignoreExpiration: false rejects expired ones. getOrThrow() stops the application from starting when the secret is missing, instead of running with an empty key. The algorithms list blocks algorithm substitution: in the test a token signed with HS512 got a 401. Whatever validate() returns lands in req.user.
We store passwords only as hashes:
1// Password hashing
2import { BadRequestException, Injectable } from '@nestjs/common';
3import * as bcrypt from 'bcrypt';
4
5@Injectable()
6export class CryptoService {
7 async hashPassword(password: string): Promise<string> {
8 // bcrypt reads only 72 bytes - reject a longer password instead of silently truncating it
9 if (Buffer.byteLength(password, 'utf8') > 72) {
10 throw new BadRequestException('Password exceeds 72 bytes');
11 }
12 const saltRounds = 12;
13 return bcrypt.hash(password, saltRounds);
14 }
15
16 async validatePassword(password: string, hash: string): Promise<boolean> {
17 return bcrypt.compare(password, hash);
18 }
19}bcrypt with a cost of 12 is deliberately slow, which makes cracking hashes harder. It reads only the first 72 bytes of UTF-8, though: in a test a password that differed only after the 72nd byte passed verification, hence the length guard. For a new project I recommend Argon2id (the argon2 package), which OWASP puts first and which has no such limit.
Input Validation
Input data is validated by a DTO with class-validator decorators:
1// DTO with validation
2import { IsEmail, IsString, Length, Matches, MaxLength, MinLength } from 'class-validator';
3
4export class CreateLegionaryDto {
5 @IsString()
6 @Length(2, 50)
7 @Matches(/^[\p{L}\s'-]+$/u, { message: 'Name can only contain letters' })
8 name: string;
9
10 @IsEmail()
11 email: string;
12
13 @IsString()
14 @MinLength(15) // NIST SP 800-63B-4: length instead of composition rules
15 @MaxLength(64)
16 password: string;
17}The \p{L} class with the u flag accepts letters of every alphabet, so "Łukasz Żółtowski" passes - the first version with [a-zA-Z] rejected Polish names. The password rules come from NIST SP 800-63B-4: at least 15 characters when the password is the only login factor (8 with MFA), allowing at least 64 characters, and checking against a list of leaked passwords. With bcrypt, remember that 64 Polish characters may exceed 72 bytes.
The previous version of the lesson enforced password composition:
1// The old password composition rule - discouraged today
2@Matches(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
3 message: 'Password must contain uppercase, lowercase, number and special character'
4})NIST now explicitly forbids such rules. People answer them with a predictable "Password123!", while a long, ordinary sentence is harder to crack.
Secrets - keys to the treasury
Secrets management in production rests on three principles: secrets are encrypted, rotated regularly, and access to them is audited. Keep them in a secrets manager, e.g. HashiCorp Vault or a cloud service; environment variables are only a way of delivering them to the process. A secret never goes into code, the repository or a Docker image.
A missing secret is best detected at startup. ConfigModule.forRoot() accepts a validationSchema, e.g. from the Joi library, and checks the variables before the application comes up:
1// app.module.ts - validating the configuration at startup
2import { Module } from '@nestjs/common';
3import { ConfigModule } from '@nestjs/config';
4import Joi from 'joi';
5
6@Module({
7 imports: [
8 ConfigModule.forRoot({
9 validationSchema: Joi.object({
10 NODE_ENV: Joi.string()
11 .valid('development', 'production', 'test')
12 .default('development'),
13 PORT: Joi.number().port().default(3000),
14 DATABASE_URL: Joi.string().uri().required(),
15 JWT_SECRET: Joi.string().min(32).required(),
16 }),
17 }),
18 ],
19})
20export class AppModule {}A missing DATABASE_URL and a too short JWT_SECRET end the startup with a Config validation error listing all problems at once, and PORT already arrives as a number. In an ES module project use import Joi from 'joi': the import * as Joi form compiles, but in the test it failed with Joi.string is not a function. The NestJS documentation now recommends the Zod library for new projects, and supports Joi from version 18.
Walls around the application
You harden containers as in the Docker lesson: a user other than root, a minimal base image and vulnerability scanning. Also pin image versions instead of the :latest tag, which changes without warning and makes it impossible to reproduce exactly what runs in production. Rate limiting from the TLS lesson protects against too many requests from a single client.
The guard's chronicle
Without logs you will not detect a break-in. Production logs are written by Winston through the nest-winston module:
1// app.module.ts - the guard's chronicle in production
2import { Module } from '@nestjs/common';
3import { WinstonModule } from 'nest-winston';
4import * as winston from 'winston';
5import DailyRotateFile from 'winston-daily-rotate-file';
6
7@Module({
8 imports: [
9 WinstonModule.forRoot({
10 format: winston.format.combine(
11 winston.format.timestamp(),
12 winston.format.json(),
13 ),
14 transports: [
15 new DailyRotateFile({
16 filename: 'logs/app-%DATE%.log',
17 datePattern: 'YYYY-MM-DD',
18 maxFiles: '30d',
19 }),
20 new DailyRotateFile({
21 filename: 'logs/error-%DATE%.log',
22 datePattern: 'YYYY-MM-DD',
23 level: 'error',
24 maxFiles: '90d',
25 }),
26 ],
27 }),
28 ],
29})
30export class AppModule {}WinstonModule.forRoot() accepts the same options as winston.createLogger(). The logs are JSON, the files rotate daily, and errors have a separate file with longer retention. To make NestJS log through it as well, call app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)) in main.ts. Never log passwords or tokens.
When the barbarians get over the wall
Handling a production incident has fixed stages:
- detection and alerting of the problem,
- triage and initial response,
- mitigation and resolution of the problem,
- post-mortem analysis and improvements.
A post-mortem looks for causes in the process, not for culprits, because only then do people tell the truth. I recommend you write these stages down in a runbook before the first incident happens. In the next lesson we will prepare backups for the day the wall does fall.
Remember: the best guard does not trust the gate, he checks every key and records every attempt.
Code for this lesson: src/security.ts
1// Security Best Practices - Protection Against Barbarians
2import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
3
4// 1. JWT Authentication
5// @Injectable()
6// class JwtAuthGuard extends AuthGuard('jwt') {
7// canActivate(context: ExecutionContext) {
8// return super.canActivate(context);
9// }
10// }
11
12// 2. Role-based Authorization
13@Injectable()
14class RolesGuard implements CanActivate {
15 constructor(private requiredRoles: string[]) {}
16
17 canActivate(context: ExecutionContext): boolean {
18 const request = context.switchToHttp().getRequest();
19 const user = request.user;
20
21 if (!user || !user.roles) return false;
22
23 return this.requiredRoles.some(role =>
24 user.roles.includes(role)
25 );
26 }
27}
28
29// 3. Input Validation (class-validator)
30// class CreateLegionDto {
31// @IsString()
32// @MinLength(3)
33// @MaxLength(50)
34// name: string;
35//
36// @IsNumber()
37// @Min(100)
38// @Max(10000)
39// soldiers: number;
40//
41// @IsEnum(Province)
42// province: Province;
43// }
44
45// 4. Security Headers (Helmet)
46const securityHeaders = {
47 'X-Content-Type-Options': 'nosniff',
48 'X-Frame-Options': 'DENY',
49 'X-XSS-Protection': '1; mode=block',
50 'Strict-Transport-Security': 'max-age=31536000',
51 'Content-Security-Policy': "default-src 'self'",
52};
53
54// 5. Password Hashing (bcrypt)
55// import * as bcrypt from 'bcrypt';
56// const hash = await bcrypt.hash(password, 10);
57// const isMatch = await bcrypt.compare(password, hash);
58
59// 6. Rate Limiting - DDoS protection
60// @UseGuards(ThrottlerGuard)
61// @Throttle(100, 60) // 100 requests / 60 seconds
62
63// 7. CORS Configuration
64const corsConfig = {
65 origin: ['https://roman-empire.com'],
66 methods: ['GET', 'POST', 'PUT', 'DELETE'],
67 allowedHeaders: ['Content-Type', 'Authorization'],
68 credentials: true,
69};
70
71// 8. Environment Secrets
72// NEVER hardcode:
73// - Database passwords
74// - API keys
75// - JWT Secret
76// - Encryption keys
77// Use: .env, Docker secrets, Vault, AWS Secrets Manager
78Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Secrets management in production should:
2. Security best practices for Docker containers:
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Implement WinstonModule with transports: file, error-file, and JSON format
- Click in order
Arrange the production incident handling stages: