NestJS course · Module 9: Deployment and Infrastructure

SSL/TLS and HTTPS Configuration - the Empire's defensive shield

9 min read
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
56

Spotted 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. 1. Helmet.js in a NestJS application is used for:

  2. 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):

Useful articles