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.
npm install helmet cors express-rate-limitimport 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:
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.
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.
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.
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.
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.
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.hpp if you accept repeated query parameters, so ?role=user&role=admin cannot become an array that bypasses a string comparison.secure: true on cookies.npm audit in CI and keep Express, Helmet and your database driver current.Which Helmet default most often needs adjusting for HTML pages that load assets from a CDN?
helmet() sets protective headers in one line; extend CSP directives rather than disabling CSP.cors with an explicit origin allowlist and credentials: true when cookies or tokens cross origins./api and a much stricter one to authentication endpoints; share counters via Redis when scaling out.trust proxy behind a reverse proxy so IP-based features see the real client.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.