Security

Express.js Intermediate — Security

Express.js Intermediate/Advanced

Chapter 4 of 8  ·  Security

Security

Security in Express breaks down into four independent layers, each defending against a different class of attack: helmet sets HTTP headers that tell browsers how to behave (preventing clickjacking, MIME sniffing, and protocol downgrade); CORS controls which external origins may call your API (preventing cross-site data theft); rate limiting throttles how many requests an IP can make (preventing brute-force and abuse); and input validation/sanitisation ensures your application only processes well-formed data (preventing injections and crashes). None of these is sufficient alone — they stack.

helmet — Security Headers

helmet is a collection of small middleware functions, each setting one security-related HTTP response header. Calling helmet() enables a sensible default set. Every header it sets tells the browser to restrict some potentially dangerous behavior — none of them affect your API logic.

// npm install helmet const helmet = require('helmet'); // Use as early as possible — before routes, after app.use(express.json()) app.use(helmet()); // Disable or configure individual headers app.use(helmet({ // Content-Security-Policy — the most complex header; controls what the page can load contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", 'https://cdn.example.com'], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], upgradeInsecureRequests: [], }, }, // HSTS — tell browsers to only connect over HTTPS for 1 year strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true, preload: true, }, // Disable a header (pass false) xPoweredBy: false, // helmet already removes X-Powered-By by default frameguard: { action: 'deny' }, // X-Frame-Options: DENY referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, }));
Header set by helmetValue (default)Prevents
X-Frame-OptionsSAMEORIGINClickjacking — embedding your page in another site's iframe
X-Content-Type-OptionsnosniffMIME sniffing — browser interpreting a JS file as an image or vice versa
Strict-Transport-Securitymax-age=15552000Protocol downgrade — forces HTTPS for future visits
Content-Security-Policydefault-src 'self'; ...XSS — restricts which scripts, styles, images the page can load
Referrer-Policyno-referrerLeaking URL paths in the Referer header to third parties
Permissions-Policydisables many APIsMalicious use of camera, microphone, geolocation from injected scripts
X-DNS-Prefetch-ControloffDNS prefetching leaking visited URLs
Cross-Origin-Opener-Policysame-originCross-origin window.opener access (Spectre side-channels)

CORS — Cross-Origin Resource Sharing

Browsers block JavaScript from reading responses from a different origin (protocol + host + port). That's the same-origin policy. CORS is the mechanism that lets servers opt specific origins back in. Without CORS configuration, your API is only callable from the same origin — any SPA on a different domain gets a network error, even for valid requests.

Simple request (no preflight)

GET or POST with basic Content-Types. Browser sends request directly; server responds with Access-Control-Allow-Origin. Browser decides whether to expose the response to JS.

Preflight request (OPTIONS first)

Any request with custom headers (e.g. Authorization, Content-Type: application/json). Browser sends OPTIONS first. Server must respond with the CORS headers. If approved, the real request follows.

// npm install cors const cors = require('cors'); // ① Development — allow everything (NEVER in production) app.use(cors()); // ② Production — static allowlist const ALLOWED_ORIGINS = new Set([ 'https://app.example.com', 'https://admin.example.com', ]); app.use(cors({ origin(requestOrigin, cb) { // requestOrigin is undefined for non-browser requests (curl, server-to-server) if (!requestOrigin || ALLOWED_ORIGINS.has(requestOrigin)) { cb(null, true); // allow } else { cb(new Error(`Origin ${requestOrigin} not allowed`)); } }, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, // allow cookies/auth headers in cross-origin requests maxAge: 86400, // cache preflight result for 24 h (reduces OPTIONS requests) })); // Must explicitly handle OPTIONS for all routes when credentials: true app.options('*', cors());

⚠ credentials: true requires an explicit origin

If you set credentials: true, you cannot use origin: '*'. Browsers will block the request — the wildcard and credentials cannot be combined per the CORS spec. You must return the specific requesting origin (or a specific string) in Access-Control-Allow-Origin. The dynamic origin callback above handles this correctly.

Rate Limiting

Rate limiting caps how many requests a single IP address can make in a time window. Without it, a single client can hammer a login endpoint millions of times trying passwords (brute force), exhaust your server CPU, or run up your API costs. The key insight is that different routes warrant different limits: a login endpoint should be very strict, a public read endpoint can be generous.

// npm install express-rate-limit const rateLimit = require('express-rate-limit'); // General API limit — 100 requests per 15 minutes per IP const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 min in ms max: 100, standardHeaders: true, // RateLimit-* headers (RFC draft standard) legacyHeaders: false, // don't also send deprecated X-RateLimit-* headers message: { error: 'Too many requests, please try again later', status: 429 }, }); // Strict login limit — 5 attempts per 15 minutes per IP const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, message: { error: 'Too many login attempts, please try again in 15 minutes', status: 429 }, // Don't count successful logins against the limit skipSuccessfulRequests: true, }); // Apply generally, override for sensitive routes app.use('/api', apiLimiter); app.use('/auth/login', loginLimiter); // applied first = stricter wins
// Behind a reverse proxy (nginx, load balancer) — use X-Forwarded-For for real IPs app.set('trust proxy', 1); // trust first hop (nginx → Express) // Custom key generator — rate-limit by user ID after authentication instead of IP const authenticatedLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, keyGenerator: (req) => req.user?.sub ?? req.ip, // fall back to IP if not authed skip: (req) => req.user?.role === 'admin', // admins bypass the limit });

Headers sent by rate limiting

With standardHeaders: true, every response includes RateLimit-Limit (the cap), RateLimit-Remaining (requests left in the window), and RateLimit-Reset (Unix timestamp when the window resets). When the limit is hit, the response is 429 and includes Retry-After (seconds to wait). Client code can read these to implement back-off.

Input Validation & Sanitisation

Validation asks: "is this value acceptable?" Sanitisation asks: "can I clean this value so it's safe to use?" They solve different problems. Validation rejects bad data at the boundary. Sanitisation normalises data — trimming whitespace, lowercasing emails, converting strings to integers. The critical rule: sanitise input to normalise it, not to make it "safe" from injection — that's what parameterised queries (SQL) and output encoding (XSS) are for.

// npm install express-validator const { body, param, query, validationResult } = require('express-validator'); // Validation chain — each method is a step in the pipeline const registerRules = [ body('email') .trim().toLowerCase() // sanitise first .notEmpty().withMessage('email is required') .isEmail().withMessage('must be a valid email address') .isLength({ max: 254 }).withMessage('email too long'), body('password') .notEmpty().withMessage('password is required') .isLength({ min: 8, max: 72 }).withMessage('password must be 8–72 characters') .matches(/[A-Z]/).withMessage('password must contain an uppercase letter') .matches(/[0-9]/).withMessage('password must contain a number'), body('age') .optional() .toInt() // sanitise: string '25' → number 25 .isInt({ min: 13, max: 120 }).withMessage('age must be between 13 and 120'), body('username') .trim() .notEmpty().withMessage('username is required') .isLength({ min: 3, max: 30 }).withMessage('username must be 3–30 characters') .matches(/^[a-zA-Z0-9_]+$/).withMessage('username may only contain letters, numbers and underscores'), ]; // Middleware that reads the result and returns 422 if any rule failed function validate(req, res, next) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(422).json({ error: 'Validation failed', errors: errors.array().map(({ path, msg }) => ({ field: path, message: msg })), }); } next(); } // Route: validation rules run as middleware before the handler app.post('/auth/register', registerRules, validate, async (req, res) => { // By this point: req.body.email is trimmed+lowercased, req.body.age is a number const { email, password, username, age } = req.body; // ... create user res.status(201).json({ id: 1, email, username }); });

Custom Validators

// Check something that built-in validators can't — e.g. uniqueness in the DB body('email') .trim().toLowerCase().isEmail() .custom(async (value) => { const existing = await db.findByEmail(value); if (existing) throw new Error('Email already registered'); }), // Cross-field validation — confirm password must match password body('confirmPassword') .custom((value, { req }) => { if (value !== req.body.password) throw new Error('Passwords do not match'); return true; }), // Validate a URL param param('id') .isInt({ min: 1 }).withMessage('id must be a positive integer') .toInt(), // converts req.params.id from string '5' to number 5 // Validate a query string param query('page') .optional() .isInt({ min: 1 }).withMessage('page must be a positive integer') .toInt() .default(1), // if absent, set req.query.page = 1
SanitiserWhat it does
.trim()Remove leading/trailing whitespace
.toLowerCase()Normalise to lowercase (email, username)
.toInt()Parse to integer — NaN if not a valid integer
.toFloat()Parse to float
.toBoolean()"true"/"1" → true, anything else → false
.escape()HTML-encode < > & " ' — use for rendering user content in HTML templates, not as an XSS defence for APIs
.normalizeEmail()Normalise email per RFC: lowercase, strip dots from Gmail, etc.
.default(value)Set a default if the field is absent

Putting It All Together

// app.js — the four layers in registration order require('express-async-errors'); const express = require('express'); const helmet = require('helmet'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const app = express(); // 1. Security headers — first, before anything that touches the response app.use(helmet()); // 2. CORS — before routes, but after helmet (helmet runs on all requests) app.use(cors({ origin: ALLOWED_ORIGINS, credentials: true })); app.options('*', cors()); // 3. Body parsing app.use(express.json({ limit: '10kb' })); // limit body size too // 4. Rate limiting app.use('/api', apiLimiter); app.use('/auth/login', loginLimiter); // 5. Routes (with validation middleware inline per route) app.use('/api/v1', apiRoutes); app.use('/auth', authRoutes); // 6. Error handler — last app.use(errorHandler);

🔒 What each layer does NOT protect against

helmet doesn't prevent bad data — it sets headers. It has no effect on server-side logic or database operations.

CORS is a browser-enforcement mechanism — it doesn't block server-to-server calls, curl, or Postman. Never treat CORS as an authentication or authorisation mechanism.

Rate limiting by IP is defeated by distributed botnets. It slows attackers but doesn't stop determined ones. Always combine with account lockouts and MFA.

Input sanitisation is not an XSS defence — output encoding is. Sanitisation is for normalisation (trimming whitespace, lowercasing) and validation is for rejection. SQL injection is defeated by parameterised queries, not escaping.

Coding Challenges

Challenge 1 — helmet & CORS

Build an Express app with both helmet and cors configured for production. The CORS allowlist should contain two hardcoded origins: https://app.example.com and https://admin.example.com. Use a dynamic origin callback so credentials: true works correctly. Set maxAge: 86400 and allow Authorization and Content-Type headers. Register app.options('*', cors()) for preflight support. Add a GET /api/data route that returns { message: 'ok' }. Write supertest tests: a request from an allowed origin gets Access-Control-Allow-Origin matching that origin; a request from a disallowed origin does NOT get Access-Control-Allow-Origin (or gets an error); the response includes the security headers set by helmet (X-Frame-Options, X-Content-Type-Options); an OPTIONS preflight to /api/data from an allowed origin returns 204 with Access-Control-Allow-Methods; the response does NOT include X-Powered-By: Express.

View sample solution ↗

Challenge 2 — Rate Limiting

Add two rate limiters to an app: a general apiLimiter (10 requests per minute per IP — low cap makes it easy to test) applied to /api, and a strict loginLimiter (3 attempts per minute) applied to POST /auth/login. Both should use standardHeaders: true and return a JSON error with status: 429 when hit. Write supertest tests: the first N requests to /api/data return 200; the (N+1)th request returns 429 with a JSON error body; the response headers include RateLimit-Limit and RateLimit-Remaining; the login endpoint has a tighter limit (hits 429 sooner than the API limiter); a skip function that exempts requests with header X-Admin: true allows unlimited requests from that header. (Hint: supertest fires requests against the same app instance in the same process — the in-memory counter persists between requests in one test, so you can exhaust it by looping.)

View sample solution ↗

Challenge 3 — Input Validation & Sanitisation

Build a POST /auth/register endpoint with a express-validator chain. Rules: email — trim, lowercase, required, valid email format, max 254 chars; password — required, 8–72 chars, must contain at least one uppercase letter and one digit; username — trim, required, 3–30 chars, only letters/numbers/underscores; age — optional, convert to integer, must be 13–120. A reusable validate middleware reads validationResult(req) and returns 422 with { errors: [{ field, message }] } if any rule fails. On success return 201 with the sanitised values (email lowercased, username trimmed, age as integer). Write supertest tests: 201 for valid input; 422 for missing email; 422 for invalid email format; 422 for password under 8 chars; 422 for password without a digit; 422 for username with spaces; 422 for age of 10 (under minimum); the response body for a valid POST contains the email in lowercase (even if submitted in mixed case); age in the response is a number, not a string.

View sample solution ↗