Node.js Backend Fundamentals with Express · Data, Auth, Tests and Production
Security Headers, CORS and Rate Limiting
Add helmet for security headers, configure CORS for the browsers you trust, and rate-limit sensitive endpoints.
What you'll build
An API with helmet security headers, CORS that allows only your frontend's origin, a request body size limit, and a rate limit on the login route that answers 429 after too many attempts.
The idea
- helmet sets defensive HTTP headers (Content-Security-Policy, X-Content-Type-Options, Strict-Transport-Security...) and removes
X-Powered-By. - CORS controls which browser origins may call your API with JavaScript; it is not authentication. Allow specific origins, not
*, for authenticated APIs. - Rate limiting slows down brute force and abuse; apply stricter limits to login, password reset and expensive endpoints. Behind a proxy, set
trust proxyso the client IP is correct. - Body size limits (
express.json({ limit: "100kb" })) stop huge payloads from using memory.
The code
import express from "express";
import helmet from "helmet";
import cors from "cors";
import { rateLimit } from "express-rate-limit";
const app = express();
app.use(helmet());
app.use(cors({ origin: ["https://app.example.com"] }));
app.use(express.json({ limit: "10kb" }));
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 3, standardHeaders: "draft-8", legacyHeaders: false });
app.post("/login", loginLimiter, (req, res) => res.status(401).json({ error: "wrong email or password" }));
app.get("/public", (req, res) => res.json({ ok: true }));
app.use((err, req, res, next) => res.status(err.status ?? 500).json({ error: err.type ?? "error" }));
const server = app.listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
const r = await fetch(`${base}/public`, { headers: { Origin: "https://app.example.com" } });
for (const h of ["x-powered-by", "x-content-type-options", "strict-transport-security", "access-control-allow-origin"]) {
console.log(`${h}: ${r.headers.get(h)}`);
}
const evil = await fetch(`${base}/public`, { headers: { Origin: "https://evil.example" } });
console.log("allowed origin for evil.example:", evil.headers.get("access-control-allow-origin"));
const statuses = [];
for (let i = 0; i < 5; i++) statuses.push((await fetch(`${base}/login`, { method: "POST" })).status);
console.log("login attempts:", statuses.join(" "));
const big = await fetch(`${base}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ x: "a".repeat(20000) }) });
console.log("20 kB body:", big.status);
server.close();
Output
x-powered-by: null x-content-type-options: nosniff strict-transport-security: max-age=31536000; includeSubDomains access-control-allow-origin: https://app.example.com allowed origin for evil.example: null login attempts: 401 401 401 429 429 20 kB body: 413
Run this on your computer — see “Get Started” for setup.
Common mistakes
cors()with no options on an authenticated API, allowing every origin.- Believing CORS protects the API from non-browser clients; it doesn't.
- Rate limiting by IP behind a load balancer without
app.set("trust proxy", ...), so every client shares one limit. - In-memory rate limit stores with several instances; use Redis or the gateway's limits.
Key points
- helmet for security headers; small body limits.
- CORS lists the browser origins you trust; it isn't access control.
- Rate-limit authentication and expensive routes, with a shared store across instances.
Further reading: Express security best practices · helmet · express-rate-limit
Practice questions
Does CORS protect an API from unauthorised clients?
Answer
No. CORS only tells browsers which origins may read responses from JavaScript; curl and servers ignore it. Use authentication and authorisation for access control.
Why set trust proxy when rate limiting behind a load balancer?
Answer
Otherwise req.ip is the load balancer's address, so all clients share one limit; trust proxy makes Express use X-Forwarded-For.
What does helmet do?
Answer
Sets security-related HTTP headers (CSP, HSTS, X-Content-Type-Options and others) and removes X-Powered-By.
Open in the app to mark lessons complete, take notes and track your progress.