Safeguard
Security Guides

Securing Express Applications: A Layered Playbook

Express gives you almost no security by default. This playbook layers the middleware, headers, rate limits, session hardening, and input validation that turn a bare Express app into a defensible one — with Express 5 in mind.

Priya Mehta
Security Researcher
5 min read

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

LayerControlPackage / setting
HeadersHelmet + tuned CSPhelmet
AbuseRate limit + body capexpress-rate-limit, json({limit})
ProxyExact trust proxy valueapp.set("trust proxy", n)
SessionshttpOnly/secure/sameSite cookiesexpress-session
CSRFToken on state changescsrf-csrf (not deprecated csurf)
InputSchema validation at boundaryzod / valibot
ErrorsGeneric responses, no stack tracescustom 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.