NestJS course · Module 9: Deployment and Infrastructure
SSL/TLS and HTTPS Configuration - the Empire's defensive shield
In this lesson6
Cybersecurity legionary! The password of a legionary logging in travels over café Wi-Fi in plain text, and a foreign site quietly sends requests on his behalf. Architect Vitruvius knows that every province needs fortifications: just as walls protected Rome from invaders, TLS and HTTP safeguards protect a NestJS application from attacks from the network.
SSL (Secure Sockets Layer) and its successor TLS (Transport Layer Security) are cryptographic protocols that ensure secure communication. SSL 2.0 and 3.0 are prohibited today as insecure, and TLS 1.2 and 1.3 are what is used - the name "SSL certificate" stuck out of habit. HTTPS is HTTP running over TLS, which encrypts data between the client and the server. We will build four rings of walls, from transport to application: TLS encrypts the road, Helmet sets security headers, CORS decides which sites may call the API, and rate limiting counts clients' requests.
Generating SSL certificates
The first step is obtaining certificates. In development we can generate self-signed certificates, and in production we use Let's Encrypt or commercial providers.
Development certificates (self-signed)
The script calls openssl through execSync and saves the key and the certificate in the certs directory:
1// scripts/generate-dev-cert.ts
2import { execSync } from 'node:child_process';
3import * as fs from 'node:fs';
4import * as path from 'node:path';
5
6// Directory computed from the project directory - ES modules have no __dirname
7const certsDir = path.join(process.cwd(), 'certs');
8
9// Create the certificates directory
10if (!fs.existsSync(certsDir)) {
11 fs.mkdirSync(certsDir, { recursive: true });
12}
13
14// Generate a private key and a certificate with names in subjectAltName
15execSync(`openssl req -x509 -newkey rsa:4096 \
16 -keyout ${certsDir}/key.pem \
17 -out ${certsDir}/cert.pem \
18 -days 365 -nodes \
19 -subj "/C=PL/ST=Roma/L=Roma/O=Imperium/CN=localhost" \
20 -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"`);
21
22console.log('Development certificates generated in the certs/ directory');-nodes leaves the key without a passphrase (OpenSSL 3 calls this option -noenc, but the old name also works in LibreSSL on macOS). -addext adds names in the subjectAltName field: browsers have ignored the CN alone for years, so they would reject a certificate without it. We compute the directory from process.cwd(), because a new NestJS 12 project is an ES module, where __dirname does not exist.
HTTPS configuration in NestJS
NestFactory.create() accepts httpsOptions with the key and the certificate:
1// src/main.ts - uruchomienie serwera z HTTPS
2import { NestFactory } from '@nestjs/core';
3import { NestExpressApplication } from '@nestjs/platform-express';
4import { AppModule } from './app.module';
5import * as fs from 'node:fs';
6import * as path from 'node:path';
7
8async function bootstrap() {
9 const isProduction = process.env.NODE_ENV === 'production';
10
11 // HTTPS options - SSL certificates
12 const httpsOptions = isProduction
13 ? {
14 key: fs.readFileSync(process.env.SSL_KEY_PATH),
15 cert: fs.readFileSync(process.env.SSL_CERT_PATH),
16 ca: fs.readFileSync(process.env.SSL_CA_PATH), // Certificate chain
17 }
18 : {
19 key: fs.readFileSync(path.join(process.cwd(), 'certs', 'key.pem')),
20 cert: fs.readFileSync(path.join(process.cwd(), 'certs', 'cert.pem')),
21 };
22
23 const app = await NestFactory.create<NestExpressApplication>(AppModule, { httpsOptions });
24
25 // Behind a reverse proxy: trust the X-Forwarded-* headers from the first hop
26 app.set('trust proxy', 1);
27
28 // Force the HTTP -> HTTPS redirect
29 if (isProduction) {
30 app.use((req, res, next) => {
31 if (!req.secure) {
32 // The address comes from configuration, not from the Host header set by the client
33 return res.redirect(301, `https://${process.env.PUBLIC_HOST}${req.originalUrl}`);
34 }
35 next();
36 });
37 }
38
39 await app.listen(process.env.PORT || 3000);
40}
41bootstrap();TLS ends either in Node.js itself (httpsOptions) or in a reverse proxy that forwards traffic over plain HTTP with an X-Forwarded-Proto header. trust proxy tells Express to trust that header, so req.secure works in both models. The first version checked the header alone, and without a proxy it is absent - every request got a redirect and a loop formed. The target address comes from configuration, because the Host header is set by the client.
Helmet.js - HTTP security headers
Helmet is like a centurion's helmet - it protects the head, that is the HTTP headers, from the most common attacks:
1// src/main.ts - Helmet configuration
2import helmet from 'helmet';
3
4async function bootstrap() {
5 const app = await NestFactory.create(AppModule);
6
7 // Basic Helmet configuration - enough for most APIs:
8 // app.use(helmet());
9
10 // Advanced Helmet configuration (instead of the basic one)
11 app.use(helmet({
12 // Content-Security-Policy - controls where resources are loaded from
13 contentSecurityPolicy: {
14 directives: {
15 defaultSrc: ["'self'"],
16 scriptSrc: ["'self'"],
17 styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
18 fontSrc: ["'self'", 'https://fonts.gstatic.com'],
19 imgSrc: ["'self'", 'data:', 'https:'],
20 connectSrc: ["'self'", ...(process.env.API_URL ? [process.env.API_URL] : [])],
21 frameSrc: ["'none'"],
22 objectSrc: ["'none'"],
23 },
24 },
25 // Strict-Transport-Security - enforces HTTPS
26 strictTransportSecurity: {
27 maxAge: 31536000, // 1 year
28 includeSubDomains: true,
29 preload: true,
30 },
31 // X-Frame-Options - prevents clickjacking
32 xFrameOptions: { action: 'deny' },
33 // X-Content-Type-Options - prevents MIME sniffing
34 xContentTypeOptions: true,
35 // Referrer-Policy
36 referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
37 }));
38}The option names come from Helmet 8; the former hsts, frameguard and noSniff are deprecated aliases. 'unsafe-inline' is gone from scriptSrc, because it switched off CSP's protection against XSS. preload puts the domain on the browsers' HSTS list, and that is hard to undo, so enable it deliberately. Since NestJS 12.1 the same default headers are set by the built-in app.useSecurityHeaders().
CORS for production
CORS (Cross-Origin Resource Sharing) controls which domains may talk to our API. It is like the list of the Empire's trusted ambassadors:
1// src/main.ts - production CORS configuration
2import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
3
4const corsOptions: CorsOptions = {
5 origin: (origin, callback) => {
6 const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [];
7
8 // Allow requests without an origin (mobile, Postman)
9 if (!origin) return callback(null, true);
10
11 if (allowedOrigins.includes(origin)) {
12 callback(null, true);
13 } else {
14 // false = no CORS headers; the browser blocks the response by itself
15 callback(null, false);
16 }
17 },
18 methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
19 allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
20 exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
21 credentials: true,
22 maxAge: 86400, // Cache the preflight for 24h (Chromium caps it at 2h)
23};
24
25app.enableCors(corsOptions);A foreign domain simply gets no CORS headers; the first version returned an error, which ended in a 500 response and junk in the logs. CORS protects only browser users - Postman or a mobile app ignore it, so it does not replace authorization.
Rate Limiting with @nestjs/throttler
Rate limiting is the guards at the gates who count how many people enter in a given time:
1// src/app.module.ts - ThrottlerModule configuration
2import { Module } from '@nestjs/common';
3import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
4import { APP_GUARD } from '@nestjs/core';
5
6@Module({
7 imports: [
8 ThrottlerModule.forRoot([
9 {
10 name: 'short', // Short-term limit
11 ttl: 1000, // 1 second
12 limit: 3, // Max 3 requests per second
13 },
14 {
15 name: 'medium', // Medium limit
16 ttl: 10000, // 10 seconds
17 limit: 20, // Max 20 requests per 10 seconds
18 },
19 {
20 name: 'long', // Long-term limit
21 ttl: 60000, // 1 minute
22 limit: 100, // Max 100 requests per minute
23 },
24 ]),
25 ],
26 providers: [
27 {
28 provide: APP_GUARD,
29 useClass: ThrottlerGuard,
30 },
31 ],
32})
33export class AppModule {}ttl is given in milliseconds, and all three limits apply at the same time. The throttler protects against brute force and API abuse, but not against a real DDoS, which clogs the link before the traffic reaches Node.js. Behind a proxy set trust proxy, and with several instances use a shared store in Redis.
Decorators change the limits for individual endpoints:
1// Disabling rate limiting for specific endpoints
2import { SkipThrottle, Throttle } from '@nestjs/throttler';
3
4@Controller('legions')
5export class LegionsController {
6 @SkipThrottle({ short: true, medium: true, long: true }) // No limit for the health check
7 @Get('health')
8 healthCheck() {
9 return { status: 'ok' };
10 }
11
12 @Throttle({ short: { ttl: 1000, limit: 1 } }) // A stricter limit
13 @Post('login')
14 login() {
15 // Login limited to 1 req/s
16 }
17}With named limits, @SkipThrottle() without arguments disables none of them - in the test the health check got a 429 after three requests, which is why we list the names. @Throttle changes only short; medium and long still apply.
CSRF protection
CSRF (Cross-Site Request Forgery) is an attack in which a malicious site makes requests on behalf of a logged-in user. It concerns login with cookies; the browser does not attach a JWT in the Authorization header by itself. Older projects protected themselves with the csurf package:
1// src/main.ts - CSRF protection with csurf (deprecated package, for old projects only)
2import csurf from 'csurf';
3import cookieParser from 'cookie-parser';
4
5async function bootstrap() {
6 const app = await NestFactory.create(AppModule);
7
8 // csurf with the cookie option needs cookie-parser first
9 app.use(cookieParser());
10
11 // CSRF protection (for applications with sessions/cookies)
12 app.use(csurf({
13 cookie: {
14 httpOnly: true,
15 secure: process.env.NODE_ENV === 'production',
16 sameSite: 'strict',
17 },
18 }));
19
20 // Middleware setting the CSRF token in the response
21 app.use((req, res, next) => {
22 res.cookie('XSRF-TOKEN', req.csrfToken(), {
23 httpOnly: false, // The frontend must be able to read it
24 secure: process.env.NODE_ENV === 'production',
25 sameSite: 'strict',
26 });
27 next();
28 });
29}csurf is archived and marked as deprecated. It also needs cookie-parser before it - without it every request in the test ended with a 500 error. NestJS 12.1 has built-in protection:
1// src/main.ts - built-in CSRF protection (NestJS 12.1+)
2async function bootstrap() {
3 const app = await NestFactory.create(AppModule);
4
5 app.enableCsrfProtection({
6 // A frontend on another domain that may send POSTs with cookies
7 trustedOrigins: ['https://admin.imperium.rome'],
8 });
9
10 await app.listen(3000);
11}enableCsrfProtection() checks the Sec-Fetch-Site header that browsers attach to requests. In the test a POST from a foreign site got a 403, while a GET, requests without browser headers and trusted domains went through. The NestJS documentation leaves tokens, e.g. from the csrf-csrf package, for very old browsers.
Complete security configuration
Helmet middleware can also be attached in a module:
1// src/security/security.module.ts
2import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
3import { ThrottlerModule } from '@nestjs/throttler';
4import helmet from 'helmet';
5
6@Module({
7 imports: [
8 ThrottlerModule.forRoot([
9 { name: 'short', ttl: 1000, limit: 3 },
10 { name: 'long', ttl: 60000, limit: 100 },
11 ]),
12 ],
13})
14export class SecurityModule implements NestModule {
15 configure(consumer: MiddlewareConsumer) {
16 consumer
17 .apply(
18 helmet(),
19 helmet.contentSecurityPolicy({
20 directives: {
21 defaultSrc: ["'self'"],
22 scriptSrc: ["'self'"],
23 },
24 }),
25 )
26 .forRoutes('*');
27 }
28}helmet() already sets a default CSP, and the second call overwrites it - a single call with the contentSecurityPolicy option would be enough.
Security is not a one-off task but a continuous process - like defending the walls, which required constant vigilance from the guards. Every layer (TLS, Helmet, CORS, rate limiting, CSRF) adds another wall. I recommend you start from Helmet's defaults and add exceptions only when something stops working. In the next lesson we will build a CI/CD pipeline that checks these walls on every deployment.
Remember: one wall is never enough - the Empire defends itself with rings, from the road to the gate.
Code for this lesson: src/security-config.ts
1// SSL/TLS and HTTPS Configuration - Defensive Shield of the Empire
2import { NestFactory } from '@nestjs/core';
3import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
4import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
5import { APP_GUARD } from '@nestjs/core';
6import helmet from 'helmet';
7import * as fs from 'fs';
8
9// 1. HTTPS Configuration
10async function bootstrap() {
11 const httpsOptions = {
12 key: fs.readFileSync(process.env.SSL_KEY_PATH),
13 cert: fs.readFileSync(process.env.SSL_CERT_PATH),
14 };
15
16 // TODO: Create the application with httpsOptions
17 // const app = await NestFactory.create(AppModule, { httpsOptions });
18
19 // 2. Helmet - security headers
20 // app.use(helmet({
21 // contentSecurityPolicy: {
22 // directives: {
23 // defaultSrc: ["'self'"],
24 // scriptSrc: ["'self'"],
25 // },
26 // },
27 // hsts: { maxAge: 31536000, includeSubDomains: true },
28 // frameguard: { action: 'deny' },
29 // }));
30
31 // 3. CORS for production
32 // app.enableCors({
33 // origin: process.env.CORS_ORIGINS?.split(','),
34 // methods: ['GET', 'POST', 'PUT', 'DELETE'],
35 // credentials: true,
36 // });
37}
38
39// 4. Rate Limiting z @nestjs/throttler
40@Module({
41 imports: [
42 ThrottlerModule.forRoot([
43 { name: 'short', ttl: 1000, limit: 3 },
44 { name: 'long', ttl: 60000, limit: 100 },
45 ]),
46 ],
47 providers: [
48 { provide: APP_GUARD, useClass: ThrottlerGuard },
49 ],
50})
51export class SecurityModule {}
52
53// TODO: Add Helmet middleware to SecurityModule
54// TODO: Configure CORS with a list of allowed domains
55// TODO: Add CSRF protection for endpoints that use cookies
56Spotted 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. Helmet.js in a NestJS application is used for:
2. @nestjs/throttler in NestJS is used for:
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Implement SecurityModule with ThrottlerModule, Helmet middleware, and CORS configuration
- Vertical ordering
Arrange the NestJS application security layers from lowest (transport) to highest (application):