12-factor app config, Helmet security headers, express-rate-limit, CORS, HTTPS enforcement, secure cookie flags, and secrets management patterns.
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# 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;
dotenv for local development
bashnpm install dotenv
bash# .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
bash# .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
bash# .gitignore — must include .env .env.local .env.*.local
Load dotenv once, at the very start of your application:
typescript// src/index.ts — first line before any other imports import 'dotenv/config'; // or equivalently: import dotenv from 'dotenv'; dotenv.config();
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// 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:
typescript// Before const port = parseInt(process.env.PORT ?? '3000'); // After import { env } from './config/env.js'; const port = env.PORT; // already a number
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.
bashnpm install helmet
typescriptimport 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 |
For APIs returning JSON, you can relax the CSP since there is no HTML to protect:
typescriptapp.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'"], imgSrc: ["'self'", 'data:', 'https:'], }, }, crossOriginEmbedderPolicy: false, // disable if serving assets to other origins }) );
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.
bashnpm install cors npm install -D @types/cors
The wrong way — a development shortcut that leaks into production:
typescriptapp.use(cors()); // allows ALL origins — never do this in production
The right way — explicit allow list:
typescriptimport 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) 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:
bash# 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.
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.
bashnpm install express-rate-limit
Global rate limit — apply to all routes:
typescriptimport 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:
typescriptexport 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:
typescript// src/app.ts app.use(globalLimiter); // src/routes/auth.routes.ts router.post('/login', authLimiter, authController.login); router.post('/register', registerLimiter, authController.register); router.post('/refresh', authLimiter, authController.refresh);
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:
bashnpm install rate-limit-redis ioredis
typescriptimport RedisStore from 'rate-limit-redis'; import Redis from 'ioredis'; import { env } from './config/env.js'; const redis = new Redis(env.REDIS_URL); export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, store: new RedisStore({ sendCommand: (...args: string[]) => redis.call(...args), }), });
Secure Cookie Flags
When storing refresh tokens in HTTP-only cookies (recommended over response body), these flags are non-negotiable:
typescript// src/controllers/auth.controller.ts import { env } from '../config/env.js'; const isProduction = env.NODE_ENV === 'production'; 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 }); } export const login = asyncHandler(async (req, res) => { const result = await authService.login(req.body); setRefreshTokenCookie(res, result.refreshToken); res.json({ accessToken: result.accessToken, user: result.user }); }); export const refresh = asyncHandler(async (req, res) => { const refreshToken = req.cookies.refreshToken; // need cookie-parser if (!refreshToken) throw new UnauthorizedError('Refresh token required'); const tokens = await authService.refresh(refreshToken); setRefreshTokenCookie(res, tokens.refreshToken); res.json({ accessToken: tokens.accessToken }); }); export const logout = asyncHandler(async (req, res) => { const refreshToken = req.cookies.refreshToken; if (refreshToken) await authService.logout(refreshToken); res.clearCookie('refreshToken', { path: '/auth' }); res.status(204).send(); });
Install cookie-parser to read cookies:
bashnpm install cookie-parser npm install -D @types/cookie-parser
typescriptimport cookieParser from 'cookie-parser'; app.use(cookieParser());
| 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 |
Additional Security Practices
Disable the X-Powered-By header — don't advertise your stack:
typescriptapp.disable('x-powered-by'); // Helmet does this automatically — don't need both
Sanitise MongoDB/NoSQL injection (if using MongoDB):
bashnpm install express-mongo-sanitize
typescriptimport mongoSanitize from 'express-mongo-sanitize'; app.use(mongoSanitize()); // strips $ and . from req.body, req.query, req.params
Request size limits — prevent large payloads from exhausting memory:
typescriptapp.use(express.json({ limit: '10kb' })); // reject JSON bodies > 10kb app.use(express.urlencoded({ limit: '10kb', extended: true }));
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:
typescriptimport https from 'https'; import fs from 'fs'; const httpsOptions = { key: fs.readFileSync('./certs/privkey.pem'), cert: fs.readFileSync('./certs/fullchain.pem'), }; https.createServer(httpsOptions, app).listen(443);
Putting It Together: Security Middleware Stack
The order matters — helmet and rate limiters should run before routes:
typescript// 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)); // Parsing app.use(express.json({ limit: '10kb' })); app.use(express.urlencoded({ extended: true, limit: '10kb' })); app.use(cookieParser()); // Rate limiting + request tracking app.use(globalLimiter); app.use(requestId); // Routes app.use('/auth', authRouter); app.use('/users', usersRouter); app.use('/posts', postsRouter); // Error handler — last app.use(errorHandler); export default app;
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=Strictcookies 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.