Default Express is minimal by design; production exposure needs explicit defences against common attacks.
Express's own best-practice guide boils down to a short checklist. Work through it for every production app.
Security headers with helmet. app.use(helmet()) sets a sensible bundle: Content-Security-Policy (limits where scripts and other resources load from — the strongest XSS mitigation for HTML apps), Strict-Transport-Security (forces HTTPS), X-Content-Type-Options: nosniff, Referrer-Policy, Cross-Origin-* policies, and it removes X-Powered-By. Tune the CSP directives for your front-end rather than disabling CSP. Pure JSON APIs need fewer headers but helmet's defaults are harmless there.
Rate limiting. express-rate-limit caps requests per client per window. Apply a generous global limit and strict ones on expensive or sensitive routes: login, password reset, signup, search. Its default memory store is per process; with several instances use a shared store (Redis). It keys on req.ip, so configure trust proxy correctly or every user behind your load balancer shares one bucket.
Limit input. Keep body-parser limits small, validate everything, cap pagination sizes, and set server timeouts so slow clients can't hold connections forever.
Cookies. httpOnly, secure, sameSite: "lax" (or "strict"), a scoped path, and a non-default session cookie name so the stack is less obvious.
CSRF. If you authenticate with cookies, sameSite cookies stop most cross-site form posts; for older clients or sameSite: "none", add CSRF tokens. Bearer tokens in headers aren't vulnerable to CSRF.
Errors and info leaks. Run with NODE_ENV=production, never return stack traces, and don't echo raw input into HTML without escaping.
Dependencies. npm audit in CI, keep Express and middleware updated, and watch the Express security-release announcements.
Example
import express from "express";
import helmet from "helmet";
import { rateLimit } from "express-rate-limit";
const app = express();
app.set("trust proxy", 1); // behind one load balancer (see deployment lesson)
app.use(helmet({
contentSecurityPolicy: {
directives: { "script-src": ["'self'", "https://cdn.example.com"] }, // merged with defaults
},
}));
app.use(rateLimit({ windowMs: 60_000, limit: 300, standardHeaders: "draft-8", legacyHeaders: false }));
const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 5, skipSuccessfulRequests: true });
app.use(express.json({ limit: "20kb" }));
app.post("/login", loginLimiter, (req, res) => {
// ... verify credentials ...
res.cookie("sid", "opaque-session-id", { httpOnly: true, secure: true, sameSite: "lax", maxAge: 3_600_000 });
res.sendStatus(204);
});
const server = app.listen(3000);
server.requestTimeout = 30_000; // drop requests that take too long to arriveRemember
helmet()adds CSP, HSTS, nosniff and more; tune CSP instead of disabling it- Rate-limit globally and strictly on auth routes; share the store across instances
- Small body limits, server timeouts and validated input limit DoS
- Secure cookie flags, NODE_ENV=production, patched dependencies
Watch out for
- Rate limiting behind a proxy without
trust proxy, so all users share the proxy's IP bucket.