What this module covers: An API that works on your machine is not a production API. This module covers the 12-factor approach to configuration so secrets never end up in source control, the HTTP security headers that close the most common attack vectors, CORS configured correctly so browsers can reach your API, rate limiting that stops brute-force attacks, and the secure cookie flags that protect tokens from XSS. These are not optional polish — they are the baseline that separates hobby projects from production systems.
The Twelve-Factor App methodology's rule on configuration: store config in the environment, never in the code. Every value that changes between environments (dev, staging, production) is configuration. Every credential is configuration.
# Wrong — committing a secret, even if you delete it later, it's in git history const DB_URL = 'postgresql://admin:supersecret@prod-db:5432/myapp';
# Right — read from the environment const DB_URL = process.env.DATABASE_URL;
# .env — never commit this file DATABASE_URL=postgresql://localhost:5432/myapp_dev JWT_ACCESS_SECRET=local-dev-secret-at-least-32-characters-long JWT_REFRESH_SECRET=local-refresh-secret-also-32-characters-long PORT=3000 NODE_ENV=development REDIS_URL=redis://localhost:6379
# .env.example — commit this file, no real values DATABASE_URL=postgresql://localhost:5432/myapp_dev JWT_ACCESS_SECRET=generate-with-node-e-crypto-randomBytes-64-hex JWT_REFRESH_SECRET=generate-with-node-e-crypto-randomBytes-64-hex PORT=3000 NODE_ENV=development REDIS_URL=redis://localhost:6379
# .gitignore — must include .env .env.local .env.*.local
Load dotenv once, at the very start of your application:
// src/index.ts — first line before any other imports import 'dotenv/config';
// or equivalently: import dotenv from 'dotenv'; dotenv.config();
Raw process.env is untyped — every value is string | undefined. Validate it at startup so the app fails fast with a clear error instead of silently failing later:
// src/config/env.ts import { z } from 'zod';
const envSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET must be at least 32 characters'), JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'), JWT_ACCESS_EXPIRY: z.string().default('15m'), JWT_REFRESH_EXPIRY: z.string().default('7d'), REDIS_URL: z.string().url().optional(), CORS_ORIGIN: z.string().default('http://localhost:5173'), RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000), // 15 min RATE_LIMIT_MAX: z.coerce.number().default(100), });
// Parse immediately — if invalid, throw with clear error message and exit const parsed = envSchema.safeParse(process.env);
if (!parsed.success) { console.error('❌ Invalid environment configuration:'); console.error(parsed.error.format()); process.exit(1); }
export const env = parsed.data; // env.PORT is number, not string | undefined // env.DATABASE_URL is string, not string | undefined
Import env instead of process.env everywhere:
// Before const port = parseInt(process.env.PORT ?? '3000');
// After import { env } from './config/env.js'; const port = env.PORT; // already a number
.env files solve secrets management for local development — they keep credentials out of source control on your laptop. They do not solve it in production. A .env file sitting on a server is a plaintext file with no access control, no rotation, and no audit trail: anyone who can read the filesystem (a misconfigured backup, a debug endpoint that shells out, a compromised dependency) reads every secret at once.
Production systems fetch secrets from a dedicated secrets manager at startup instead of a file:
// src/index.ts — fetch secrets before the Zod-validated env module is loaded const secrets = await loadSecrets(); Object.assign(process.env, secrets); // now import './config/env.js' as usual — it parses process.env same as before
HashiCorp Vault is the equivalent for teams not on AWS — same idea, different client (node-vault), with the added benefit of short-lived, automatically-rotated credentials (a database password that expires in an hour instead of living in a file forever).
What this buys you over .env in production:
Access control — IAM/Vault policies decide who can read which secret, not filesystem permissions.
Rotation — rotate a compromised credential without redeploying every service that reads a .env file.
Audit trail — every read is logged: which service, which secret, when.
.env for local dev, a secrets manager for anything deployed — they're two halves of the same practice, not competing approaches.
Helmet sets HTTP response headers that tell browsers how to handle your content safely. It prevents a class of attacks that have nothing to do with your application logic.
npm install helmet
import helmet from 'helmet';
app.use(helmet());
That one line sets these headers (among others):
Header
What it does
Content-Security-Policy
Restricts which resources the browser can load — blocks inline script injection
X-Frame-Options
Prevents clickjacking (your page can't be embedded in an iframe)
X-Content-Type-Options: nosniff
Prevents MIME type sniffing — browser uses declared content type
Strict-Transport-Security
Forces HTTPS for future requests (HSTS)
Referrer-Policy
Controls how much URL info is sent in the Referer header
X-Permitted-Cross-Domain-Policies
Blocks Adobe Flash/Acrobat cross-domain requests
CSP exists to restrict what a browser rendering your response as HTML is allowed to load and execute. A pure JSON API never asks a browser to render its response as HTML, so CSP has nothing to protect there — it's not a matter of "relaxing" the directives, it's that the entire header is largely moot. (A directive list that just restates self for every source — as an earlier version of this section showed — isn't looser than Helmet's own default policy; it's the same policy with fewer entries typed out.)
For a pure JSON API, pick one of two honest options instead of a fake "relaxed" config:
Disable CSP entirely — the straightforward choice when every response is application/json:
Or keep a minimal CSP as defense-in-depth for the HTML you didn't mean to serve — Express's default error handler, a stack trace leaking into a response, or a status/docs page mounted on the same origin can still render as HTML. A default-src 'none' policy costs nothing and blocks script execution if that ever happens:
app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], // nothing is expected to load — there's no HTML to protect otherwise }, }, crossOriginEmbedderPolicy: false, // disable if serving assets to other origins }) );
If your service ever serves actual HTML (a server-rendered admin panel, a docs UI), that's when a real, scoped-down CSP — allowing exactly the scripts/styles/images that page needs — earns its keep.
app.use( cors({ origin: (origin, callback) => { // Allow requests with no origin (mobile apps, curl, Postman, server-to-server) if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error(`Origin ${origin} not allowed by CORS`)); } }, credentials: true, // allow cookies to be sent cross-origin methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], exposedHeaders: ['X-Request-ID'], // headers the browser can read maxAge: 86400, // preflight cache: 24 hours }) );
In .env:
# Development — your frontend dev server CORS_ORIGIN=http://localhost:5173
# Production — comma-separated if multiple origins CORS_ORIGIN=https://myapp.com,https://www.myapp.com
Preflight requests: Before sending a non-simple request (POST with JSON, any DELETE, any custom header), browsers send an OPTIONS preflight to ask permission. Helmet and the cors package handle this automatically. If you see OPTIONS requests failing, check that your CORS config allows the method and headers being used.
Without rate limiting, a single client can flood your auth endpoints with thousands of login attempts per second. Rate limiting is the first line of defense against brute force, credential stuffing, and denial-of-service.
express-rate-limit keys its counter off req.ip. Behind a load balancer or reverse proxy, the TCP connection Express actually sees always comes from that proxy — not the end client — so req.ip is not automatically trustworthy. There are two ways to get this wrong:
Leave trust proxy unset.req.ip resolves to the load balancer's own address for every request. Every client on the internet collapses into one IP, and therefore one shared rate-limit bucket.
Set trust proxy: true. Express now trusts the entire X-Forwarded-For chain and reads the left-most entry — which the client supplies. Send X-Forwarded-For: 1.2.3.4 yourself and req.ip becomes whatever you typed.
Rate limiting without trust proxy configured is like a bouncer checking IDs off a photo that anyone in line can hand him themselves — behind a load balancer, req.ip is whatever the client claims in X-Forwarded-For unless you tell Express which hop to actually trust.
The fix is to tell Express exactly how many hops to trust, so it reads the one entry your own infrastructure appended and ignores anything the client tried to prepend:
// src/app.ts — set this before any middleware that reads req.ip // Trust exactly one hop: the load balancer/reverse proxy in front of this app. app.set('trust proxy', 1);
If there are multiple proxies in the chain (CDN → load balancer → app), set the number to match, or pass the specific trusted IP ranges instead of a hop count.
Production story: On a payments gateway running behind an AWS ALB, express-rate-limit was rolled out specifically to stop brute-force login attempts — and brute-force attempts sailed straight through anyway. Every request Express saw arrived from the ALB's single internal IP, so the entire internet shared one 100-request-per-window bucket: legitimate traffic exhausted it constantly, and an actual attacker just had to stay under the same shared ceiling everyone else was drawing from too. The fix was app.set('trust proxy', 1), which made req.ip resolve to the correct forwarded-for hop — the real client — instead of the load balancer.
Global rate limit — apply to all routes:
import rateLimit from 'express-rate-limit'; import { env } from './config/env.js';
export const globalLimiter = rateLimit({ windowMs: env.RATE_LIMIT_WINDOW_MS, // 15 minutes max: env.RATE_LIMIT_MAX, // 100 requests per window standardHeaders: true, // Return rate limit info in RateLimit-* headers legacyHeaders: false, // Disable X-RateLimit-* headers message: { error: 'Too many requests, please try again later.' }, handler: (req, res) => { res.status(429).json({ error: 'Too many requests, please try again later.' }); }, });
Stricter limits for auth endpoints:
export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // only 10 login attempts per 15 min per IP skipSuccessfulRequests: true, // only count failed attempts message: { error: 'Too many login attempts. Try again in 15 minutes.' }, });
export const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 5, // 5 registrations per hour per IP message: { error: 'Too many accounts created. Try again in an hour.' }, });
Apply globally first, then stricter limits on specific routes:
Rate limiting with Redis (for multi-server deployments):
The default in-memory store does not share state across processes. If you have two app servers, each has its own counter — a client gets 2× the allowed requests. Use Redis for a shared store:
npm install rate-limit-redis ioredis
import RedisStore from 'rate-limit-redis'; import Redis from 'ioredis'; import { env } from './config/env.js';
function setRefreshTokenCookie(res: Response, token: string) { res.cookie('refreshToken', token, { httpOnly: true, // JS cannot read this cookie — XSS-proof secure: isProduction, // HTTPS only in production sameSite: 'strict', // not sent on cross-site requests — CSRF protection maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms path: '/auth', // only sent to /auth/* routes — not every request }); }
sameSite: 'strict' blocks the classic CSRF attack — but only for requests that rely on the browser attaching the cookie automatically. Any mutating endpoint that authenticates a different way (a mobile client sending a bearer token in an Authorization header, a server-to-server call) sits outside sameSite's protection, because there's no cookie for the browser to withhold in the first place — a bearer token can't be attached by a cross-site page the way a cookie can.
The gap that actually matters: an endpoint that accepts both a bearer token and a cookie, a common pattern when the same API serves a web frontend and a mobile app. The cookie-authenticated path there still needs explicit CSRF protection — sameSite alone doesn't cover older browsers or proxies that strip the attribute.
The standard defense is a synchronizer token the frontend must read and echo back on every mutating request:
npm install csrf-csrf
// src/middleware/csrf.ts import { doubleCsrf } from 'csrf-csrf'; import { env } from '../config/env.js';
// GET route the frontend calls once to obtain a token router.get('/csrf-token', (req, res) => { res.json({ csrfToken: generateToken(req, res) }); });
// Apply to cookie-authenticated mutation routes only — // bearer-token-only routes don't need it router.post('/profile', doubleCsrfProtection, profileController.update);
An attacker's cross-site page can still trigger the cookie-carrying request, but it cannot read the token to include in the X-CSRF-Token header, so the request is rejected.
HTTPS in production — your app should run behind a TLS-terminating reverse proxy (nginx, Cloudflare, AWS ALB). The app itself typically serves HTTP internally. If you need HTTPS at the app level:
That's for the rare case where the app terminates TLS itself. The far more common need is the opposite: the app sits behind a TLS-terminating proxy and must reject or redirect any request that reaches it over plain HTTP — someone hits http://myapp.com directly, or the proxy is misconfigured. req.secure alone doesn't work here: it only reflects TLS on the app's own socket, which behind a proxy is always plain HTTP. Check the header the proxy sets instead:
// src/middleware/enforceHttps.ts import { Request, Response, NextFunction } from 'express'; import { env } from '../config/env.js';
export function enforceHttps(req: Request, res: Response, next: NextFunction) { if (env.NODE_ENV !== 'production') return next();
// req.secure reflects x-forwarded-proto too, but only once trust proxy // is configured (see the Rate Limiting section) — otherwise Express // ignores the header and this check always falls through to false. const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
if (!isSecure) { return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`); }
next(); }
// src/app.ts — after trust proxy is set, before routes app.set('trust proxy', 1); app.use(enforceHttps);
The order matters — helmet and rate limiters should run before routes:
// src/app.ts import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import { globalLimiter } from './middleware/rateLimiter.js'; import { requestId } from './middleware/requestId.js'; import { errorHandler } from './middleware/errorHandler.js';
const app = express();
// Security — first app.use(helmet()); app.use(cors(corsOptions));
12-factor config: all env-specific values in environment variables, validated with Zod at startup, never in code.
Helmet: one line that sets a dozen security headers — Content-Security-Policy, X-Frame-Options, HSTS, and more. Always use it.
CORS: explicit origin allow list from environment variables. Never cors() with no options in production.
Rate limiting: global limiter for all routes, stricter limiter on auth endpoints. Use Redis store when running multiple servers.
HTTP-only cookies: store refresh tokens in httpOnly; Secure; SameSite=Strict cookies with a scoped path — the trifecta that blocks XSS, HTTPS downgrade, and CSRF.
Request size limits: express.json({ limit: '10kb' }) prevents memory exhaustion from large payloads.
Next: connecting external services — Redis caching with the cache-aside pattern, sending email, uploading files to object storage, and making outbound HTTP requests to third-party APIs.
Knowledge Check
What is the Twelve-Factor App methodology's rule regarding configuration management?
Why is it dangerous to simply use app.use(cors()) without passing any options in a production environment?
When storing a refresh token in a cookie, which flag is responsible for ensuring that malicious JavaScript (XSS) cannot read the token?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.