Express is deliberately minimal, and that minimalism is a security liability if you don't compensate for it. Out of the box, an Express app sends no security headers, imposes no rate limit, trusts every proxy header, and returns stack traces on error. Every one of those defaults is an invitation. With Express 5 now the current major line (released GA in late 2024, with safer routing internals), it's a good moment to lay down a consistent hardening playbook. This is the ordered set of layers I apply to every Express service.
Layer 1: Send the security headers Express omits
Helmet sets a bundle of protective HTTP headers in one line. It's the highest-value, lowest-effort control you can add.
import helmet from "helmet";
app.use(helmet());
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"], // clickjacking protection
},
})
);
A tuned Content-Security-Policy is the part worth the extra effort: it's your last line of defense against XSS, because even an injected script won't execute if it violates the policy.
Layer 2: Rate-limit and bound request size
Express will happily accept a 500 MB JSON body and unlimited requests per second. Both are denial-of-service vectors. Cap them.
import rateLimit from "express-rate-limit";
app.use(express.json({ limit: "100kb" })); // bound body size
app.use(
rateLimit({
windowMs: 60_000,
limit: 100, // per IP per minute
standardHeaders: "draft-7",
})
);
Apply a stricter limit to authentication endpoints — login and password-reset routes are where credential-stuffing lands, so 5–10 attempts per window is appropriate there.
Layer 3: Configure trust proxy correctly
If you run behind a load balancer or CDN, Express needs to know so req.ip and rate limiting see the real client address — but setting it wrong lets clients spoof X-Forwarded-For. Set it to the specific number of proxies in front of you, not blanket true:
app.set("trust proxy", 1); // exactly one trusted proxy (your LB)
Layer 4: Harden sessions and cookies
Whether you use express-session or JWTs in cookies, the cookie flags are non-negotiable:
app.use(
session({
secret: process.env.SESSION_SECRET, // from a secret store, not source
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JS can't read it → mitigates XSS token theft
secure: true, // HTTPS only
sameSite: "lax", // CSRF mitigation
maxAge: 3_600_000,
},
})
);
httpOnly keeps the session cookie out of reach of any XSS payload, and sameSite blunts CSRF at the browser level.
Layer 5: Protect state-changing requests from CSRF
For cookie-based sessions, add CSRF tokens to state-changing routes. The long-standing csurf package is deprecated, so use a maintained alternative like csrf-csrf (double-submit pattern) or move to a header-based token your SPA sends explicitly. For pure token-in-Authorization-header APIs (no cookies), CSRF isn't applicable — but confirm you're truly not relying on ambient cookie auth.
Layer 6: Validate every input, centrally
Don't scatter if (!req.body.x) checks through handlers. Validate at the boundary with a schema and reject malformed requests before business logic runs:
import { z } from "zod";
const schema = z.object({
email: z.string().email().max(254),
amount: z.number().positive().max(1_000_000),
});
function validate(s) {
return (req, res, next) => {
const r = s.safeParse(req.body);
if (!r.success) return res.status(400).json(r.error.flatten());
req.valid = r.data;
next();
};
}
app.post("/transfer", validate(schema), transferHandler);
Layer 7: Fail safely and quietly
A production Express app should never leak stack traces. Add a final error handler that logs internally and returns a generic message, and set NODE_ENV=production so Express itself stops verbose error output.
app.use((err, req, res, next) => {
logger.error({ err, path: req.path });
res.status(500).json({ error: "Internal Server Error" });
});
The Express hardening checklist
| Layer | Control | Package / setting |
|---|---|---|
| Headers | Helmet + tuned CSP | helmet |
| Abuse | Rate limit + body cap | express-rate-limit, json({limit}) |
| Proxy | Exact trust proxy value | app.set("trust proxy", n) |
| Sessions | httpOnly/secure/sameSite cookies | express-session |
| CSRF | Token on state changes | csrf-csrf (not deprecated csurf) |
| Input | Schema validation at boundary | zod / valibot |
| Errors | Generic responses, no stack traces | custom handler + NODE_ENV |
Layer 8: Watch the middleware stack itself
The controls above depend on packages — helmet, your session store, body parsers, the router — that are themselves code you didn't write. Express middleware has a long history of security advisories: outdated body-parser versions, prototype-pollution bugs in query-string parsers, and ReDoS in path-matching libraries have all shipped in the ecosystem. Two habits keep this layer honest. First, order matters: security middleware (Helmet, rate limiting, body-size caps) must be registered before your routes, or requests reach handlers before the controls apply. Second, keep the stack patched and scanned, because a hardened configuration on top of a vulnerable dependency is a false sense of safety. Pin versions with a lockfile, and gate dependency updates on a scan so a routine bump doesn't quietly introduce a known CVE.
How Safeguard helps
A hardening playbook tells you what to configure; it doesn't tell you what you missed or which of your dependencies undermines it. Safeguard's dynamic application security testing crawls your running Express app to confirm the controls actually took effect — missing security headers, endpoints without rate limits, reflected XSS, and CSRF gaps — and reports what's genuinely exploitable. Software composition analysis watches the middleware and framework packages you depend on (Express itself, body parsers, session stores) for known CVEs with reachability context, and Griffin AI drafts the middleware fix. Run it in CI with the Safeguard CLI so a regression — someone disabling Helmet, say — fails the build. See Safeguard vs Checkmarx for how this differs from traditional SAST.
Get started
Express security is additive: each layer closes a class of attack the framework leaves open by default. Add them once, enforce them in CI, and verify with automated testing. Sign up free at app.safeguard.sh/register and follow the Express integration guide at docs.safeguard.sh.