Security: Helmet, CORS and Rate Limiting

Advanced
14 min

Security: Helmet, CORS and Rate Limiting

Express ships with almost no security defaults: it announces itself in a header, accepts requests from any origin, and will happily process ten thousand login attempts a minute. Three small middleware packages close most of these gaps. After this lesson you will be able to set protective HTTP headers with Helmet, configure cross-origin access precisely with the cors package, throttle abusive clients with express-rate-limit, and apply a few extra hardening rules that Express 5 makes necessary.

Helmet: secure headers in one line

bash
npm install helmet cors express-rate-limit
javascript
import helmet from "helmet"; app.use(helmet());

With no options Helmet sets a dozen headers. The ones that matter most:

| Header | Protects against | | --- | --- | | Content-Security-Policy | Cross-site scripting by restricting where scripts, styles and images may load from | | Strict-Transport-Security | Downgrade attacks; browsers remember to use HTTPS | | X-Content-Type-Options: nosniff | Browsers guessing a content type and executing a file as script | | X-Frame-Options / frame-ancestors | Clickjacking through hidden iframes | | Referrer-Policy | Leaking full URLs to third parties |

Helmet also removes X-Powered-By. The default CSP allows only same-origin resources, which breaks pages that load fonts or scripts from a CDN. Extend the directives instead of switching CSP off:

javascript
app.use(helmet({ contentSecurityPolicy: { directives: { ...helmet.contentSecurityPolicy.getDefaultDirectives(), "script-src": ["'self'", "https://cdn.jsdelivr.net"], "img-src": ["'self'", "data:", "https://images.example.com"], }, }, }));

Pure JSON APIs can keep the defaults; CSP only affects browsers rendering HTML from your server.

CORS: who may call the API from a browser

Browsers block JavaScript on https://app.example.com from reading responses of https://api.example.com unless the API opts in with Access-Control-* headers. The cors package generates them and answers the OPTIONS preflight request that browsers send before non-simple requests.

javascript
import cors from "cors"; const allowlist = (process.env.CORS_ORIGINS ?? "").split(","); app.use(cors({ origin: (origin, cb) => { if (!origin || allowlist.includes(origin)) return cb(null, true); // !origin: curl, mobile apps cb(new Error("Not allowed by CORS")); }, credentials: true, // allow cookies / Authorization on cross-site calls methods: ["GET", "POST", "PATCH", "DELETE"], allowedHeaders: ["Content-Type", "Authorization"], maxAge: 600, // cache the preflight for 10 minutes }));

credentials: true cannot be combined with origin: "*"; the browser rejects that pairing, which is why the allowlist callback exists. app.use(cors()) with no options is acceptable for a public read-only API, never for one that uses cookies.

Rate limiting

express-rate-limit counts requests per client key (the IP by default) inside a sliding window and answers 429 Too Many Requests once the limit is reached.

javascript
import { rateLimit } from "express-rate-limit"; app.set("trust proxy", 1); // read the real client IP from X-Forwarded-For behind a proxy const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 100, standardHeaders: "draft-7", // RateLimit-* headers so clients can back off legacyHeaders: false, message: { error: "Too many requests, try again later" }, }); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 5, skipSuccessfulRequests: true }); app.use("/api", apiLimiter); app.post("/api/auth/login", loginLimiter, authController.login);

A strict limiter on login, password reset and signup endpoints is the cheapest defence against credential stuffing. The default in-memory store counts per process; when you run several instances or PM2 workers, plug in rate-limit-redis so all of them share the same counters.

Extra hardening for Express 5

  • Limit body sizes: express.json({ limit: "100kb" }) stops multi-megabyte payloads from exhausting memory.
  • express-mongo-sanitize v2 writes to req.query, which is read-only in Express 5 and throws. Validate and whitelist input with Zod or express-validator instead; a schema that only accepts strings cannot let { "$gt": "" } through.
  • Use hpp if you accept repeated query parameters, so ?role=user&role=admin cannot become an array that bypasses a string comparison.
  • Serve everything over HTTPS at the proxy and set secure: true on cookies.
  • Run npm audit in CI and keep Express, Helmet and your database driver current.
Quick Quiz
Question 1 of 3

Which Helmet default most often needs adjusting for HTML pages that load assets from a CDN?

Key Takeaways

  • helmet() sets protective headers in one line; extend CSP directives rather than disabling CSP.
  • Configure cors with an explicit origin allowlist and credentials: true when cookies or tokens cross origins.
  • Apply a general rate limit to /api and a much stricter one to authentication endpoints; share counters via Redis when scaling out.
  • Set trust proxy behind a reverse proxy so IP-based features see the real client.
  • Limit body size and validate input with schemas; older sanitisers that mutate req.query break on Express 5.

Next lesson: Caching with Redis — cut database load and response times by caching hot data and HTTP responses in Redis.

Security: Helmet, CORS and Rate Limiting - Express.js | CodeYourCraft | CodeYourCraft