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.
| Header set by helmet | Value (default) | Prevents |
|---|---|---|
| X-Frame-Options | SAMEORIGIN | Clickjacking — embedding your page in another site's iframe |
| X-Content-Type-Options | nosniff | MIME sniffing — browser interpreting a JS file as an image or vice versa |
| Strict-Transport-Security | max-age=15552000 | Protocol downgrade — forces HTTPS for future visits |
| Content-Security-Policy | default-src 'self'; ... | XSS — restricts which scripts, styles, images the page can load |
| Referrer-Policy | no-referrer | Leaking URL paths in the Referer header to third parties |
| Permissions-Policy | disables many APIs | Malicious use of camera, microphone, geolocation from injected scripts |
| X-DNS-Prefetch-Control | off | DNS prefetching leaking visited URLs |
| Cross-Origin-Opener-Policy | same-origin | Cross-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.
⚠ 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.
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.
Custom Validators
| Sanitiser | What 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
🔒 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.
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.)
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.