Module P-6 — Configuration, Security Hardening, and Rate Limiting
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.
12-Factor Configuration: No Secrets in Code
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.
bash
dotenv for local development
bash
bash
bash
bash
Load dotenv once, at the very start of your application:
typescript
Config validation with Zod
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:
typescript
Import env instead of process.env everywhere:
typescript
Beyond .env: Secrets Managers in Production
.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:
typescript
typescript
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: HTTP Security Headers
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.
bash
typescript
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:
typescript
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:
typescript
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.
CORS: Cross-Origin Resource Sharing
Browsers block cross-origin requests by default. CORS is the mechanism that lets your API tell browsers which origins are allowed.
bash
The wrong way — a development shortcut that leaks into production:
typescript
The right way — explicit allow list:
typescript
In .env:
bash
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.
Rate Limiting
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.
bash
trust proxy: Telling Express Which Hop to Believe
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:
typescript
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:
typescript
Stricter limits for auth endpoints:
typescript
Apply globally first, then stricter limits on specific routes:
typescript
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:
bash
typescript
Secure Cookie Flags
When storing refresh tokens in HTTP-only cookies (recommended over response body), these flags are non-negotiable:
typescript
Install cookie-parser to read cookies:
bash
typescript
Flag
What it prevents
httpOnly
XSS — attacker's injected script can't read the token
secure
Token transmitted over cleartext HTTP
sameSite: strict
CSRF — browser doesn't send the cookie on cross-site requests
path: '/auth'
Token sent on every request, not just auth endpoints
CSRF Tokens for Non-Cookie Mutation Paths
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:
bash
typescript
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.
Additional Security Practices
Disable the X-Powered-By header — don't advertise your stack:
typescript
Sanitise MongoDB/NoSQL injection (if using MongoDB):
bash
typescript
Request size limits — prevent large payloads from exhausting memory:
typescript
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:
typescript
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:
typescript
typescript
Putting It Together: Security Middleware Stack
The order matters — helmet and rate limiters should run before routes:
typescript
Summary
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.
# Wrong — committing a secret, even if you delete it later, it's in git historyconst DB_URL ='postgresql://admin:supersecret@prod-db:5432/myapp';# Right — read from the environmentconst DB_URL = process.env.DATABASE_URL;
npminstall dotenv
# .env — never commit this fileDATABASE_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=3000NODE_ENV=development
REDIS_URL=redis://localhost:6379
# .env.example — commit this file, no real valuesDATABASE_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=3000NODE_ENV=development
REDIS_URL=redis://localhost:6379
# .gitignore — must include.env
.env.local
.env.*.local
// src/index.ts — first line before any other importsimport'dotenv/config';// or equivalently:import dotenv from'dotenv';dotenv.config();
// src/config/env.tsimport{ 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 minRATE_LIMIT_MAX: z.coerce.number().default(100),});// Parse immediately — if invalid, throw with clear error message and exitconst parsed = envSchema.safeParse(process.env);if(!parsed.success){console.error('❌ Invalid environment configuration:');console.error(parsed.error.format()); process.exit(1);}exportconst env = parsed.data;// env.PORT is number, not string | undefined// env.DATABASE_URL is string, not string | undefined
// Beforeconst port =parseInt(process.env.PORT??'3000');// Afterimport{ env }from'./config/env.js';const port = env.PORT;// already a number
// src/index.ts — fetch secrets before the Zod-validated env module is loadedconst secrets =awaitloadSecrets();Object.assign(process.env, secrets);// now import './config/env.js' as usual — it parses process.env same as before
npminstall helmet
import helmet from'helmet';app.use(helmet());
app.use(helmet({ contentSecurityPolicy:false}));
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}));
npminstall cors
npminstall-D @types/cors
app.use(cors());// allows ALL origins — never do this in production
import cors from'cors';import{ env }from'./config/env.js';const allowedOrigins = env.CORS_ORIGIN.split(',').map(o => o.trim());app.use(cors({origin:(origin, callback)=>{// Allow requests with no origin (mobile apps, curl, Postman, server-to-server)if(!origin)returncallback(null,true);if(allowedOrigins.includes(origin)){callback(null,true);}else{callback(newError(`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}));
# Development — your frontend dev serverCORS_ORIGIN=http://localhost:5173
# Production — comma-separated if multiple originsCORS_ORIGIN=https://myapp.com,https://www.myapp.com
npminstall express-rate-limit
// 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);
import rateLimit from'express-rate-limit';import{ env }from'./config/env.js';exportconst 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.'});},});
exportconst 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.'},});exportconst 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.'},});
// src/middleware/enforceHttps.tsimport{ Request, Response, NextFunction }from'express';import{ env }from'../config/env.js';exportfunctionenforceHttps(req: Request, res: Response, next: NextFunction){if(env.NODE_ENV!=='production')returnnext();// 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 routesapp.set('trust proxy',1);app.use(enforceHttps);