Skip to content

9.9.5 — Hardening: Helmet, CORS, Rate Limiting, Compression

Four packages and about twenty minutes of wiring close a large share of the ways a typical Express app gets attacked. That is an unusually good return, and it is why every production checklist contains these four names.

It is also why they get pasted in without being understood, which produces the specific failure this page is written against: an app with helmet() present, 'unsafe-inline' in its content policy, and origin: true in its CORS config. Every box is ticked and almost nothing is protected.

1. Helmet: the header zoo, decoded

app.use(helmet()) sets a dozen response headers. Knowing which attack each one blunts is the interview question and the operational necessity — a blanket helmet() that breaks your CDN gets disabled wholesale by the next engineer, which is worse than tuning it: What is Helmet and which headers does it set? [EQ-180b]

HeaderDefends againstNote
Content-Security-PolicyXSS (script injection)The big one; needs per-app tuning (sectionbelow)
Strict-Transport-Security (HSTS)SSL-stripping / downgradeBrowser refuses HTTP for maxAge; preload is one-way — verify before enabling
X-Content-Type-Options: nosniffMIME confusion (a .txt executed as JS)Free; always on
X-Frame-Options / frame-ancestorsClickjacking (your UI in a hostile iframe)CSP's frame-ancestors supersedes it
Referrer-PolicyLeaking URLs (with tokens) to third partiesno-referrer or strict-origin-when-cross-origin
X-DNS-Prefetch-Control, X-Permitted-Cross-Domain-PoliciesMinor info leaks / legacy pluginsFree hygiene
Origin-Agent-Cluster, Cross-Origin-*-PolicyCross-origin isolation (Spectre-class — 1.5)Can break embeds; test
(removes) X-Powered-ByFingerprintingHelmet strips Express's default

Every header in that table works out of the box except one. Content Security Policy is the only one you have to design, and it is worth the effort because of what it changes about a successful attack.

Without it, an attacker who manages to get <script> into one of your pages owns that page: they can read cookies, take actions as the user, and send data anywhere. With a policy like default-src 'self'; script-src 'self' 'nonce-{random}', the browser has been told which script sources it may run. The injected script does not match, so it never executes. The attack that would have been a full compromise becomes a blocked resource and a console warning.

The nonce in that example is the mechanism. On every response you generate a fresh random value, put it in the header, and put the same value on each legitimate <script> tag. An attacker injecting a script cannot guess the value, and it changes every time. This needs server-rendered HTML, since the value must be stamped in as the page is built. The alternative is listing the hash of each inline script you permit, which suits pages whose inline scripts do not change.

What you must not do is add 'unsafe-inline', which permits any inline script and therefore switches off exactly the protection you were configuring. It appears in real policies constantly, because it is what makes the errors go away.

Roll a policy out with Content-Security-Policy-Report-Only first, pointed at an endpoint that collects violations. The browser then reports what would have been blocked without blocking anything. You will find analytics snippets, embedded widgets, and inline onclick handlers that nobody remembered — the inventory is always bigger than the one in your head.

One split by application type. A pure JSON API gets little from CSP, because there is no HTML for an attacker to inject into. Send it anyway; the cost is a header, and your error pages and any documentation you serve are still HTML. An app that serves HTML needs the full policy designed properly.

2. CORS, honestly

The rule browsers enforce is the same-origin policy: a page at https://app.example.com may not read responses from https://api.example.com unless the server opts in. CORS is that opt-in, expressed in response headers. Two facts settle most confusion: Why does CORS block the browser but not Postman? [EQ-183b]

CORS is something the browser does, not something your server enforces. This is the fact that clears up almost all confusion about it. The browser is the one refusing to hand the response to the JavaScript that asked for it. Your server sent the response quite happily.

Two consequences follow, and people usually only notice the first. Postman, curl, a script, and any server-to-server call are not browsers, have no same-origin policy, and are entirely unaffected — so "it works in Postman" tells you nothing about whether your CORS setup is right. And more importantly, CORS is not access control. It does not stop anybody from calling your API. It stops a web page on another domain from reading the answer in a user's browser. Authentication and permission checks remain entirely your middleware's job (Part 8.5).

The second fact is the preflight. For anything other than a plain request — any method beyond GET, POST, or HEAD, any custom header such as Authorization, any unusual content type — the browser does not send your request first. It sends an OPTIONS request asking whether the real one would be allowed. Only if the Access-Control-Allow-* headers come back permitting it does the actual request follow.

Two things fall out of that. Preflights cost an extra round trip, so let browsers cache the answer with maxAge. And the preflight carries no credentials at all — no cookie, no Authorization header, by design. So if your authentication middleware runs before your CORS middleware, it will look at an OPTIONS request, find no credentials, and reject it. The browser then reports a generic CORS failure, and you spend an afternoon adjusting CORS options for what is actually a middleware ordering bug (9.9.1). CORS goes before auth.

javascript
app.use(cors({
  origin: (origin, cb) => cb(null, ALLOWED_ORIGINS.has(origin)),   // allow-LIST, never true
  credentials: true,                    // required for cookie auth — and then `origin: *`
  maxAge: 86_400,                       // is FORBIDDEN by spec (browsers reject the combo)
  methods: ["GET", "POST", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization", "Idempotency-Key"],
}));

One configuration is worth calling out because it looks harmless. origin: true tells the CORS middleware to reflect whatever origin asked, which means "allow everyone". Combine that with credentials: true and you have told every website on the internet that its JavaScript may make authenticated requests to your API using your users' cookies, and read the answers. Any page a logged-in user visits can now read their data from your service.

Keep an explicit list of allowed origins, loaded from configuration so it differs per environment. It is three more lines than origin: true and it is the difference between a boundary and a formality.

3. Rate limiting in Express

9.7.5 built the algorithms; the Express wiring adds three production decisions: How do you implement rate limiting in Express? [EQ-186b]

javascript
const authLimiter = rateLimit({
  windowMs: 15 * 60_000, limit: 10,                  // strict on the credential endpoint
  store: new RedisStore({ client: redis }),          // (1) SHARED across replicas
  keyGenerator: (req) => req.user?.id ?? req.ip,     // (2) identity first, IP fallback
  standardHeaders: "draft-7", legacyHeaders: false,  // RateLimit-* headers (9.6.1)
  handler: (req, res) => { throw new RateLimitedError(); },   // (3) YOUR error envelope
});

app.use("/auth/login", authLimiter);                 // strict, targeted
app.use("/api", rateLimit({ windowMs: 60_000, limit: 300, store, keyGenerator }));  // broad

(1) The counter has to be shared. Leave out the store and the counts live in each process's memory. Run four replicas and each keeps its own tally, so a limit of ten becomes an effective limit of forty — and which one you hit depends on where the load balancer sent you. Same principle as sessions in 9.9.4, same fix: put the state somewhere all the processes can see it.

(2) Key on who they are, falling back to where they are. IP addresses look like the natural key and are a poor one in both directions. An entire office, or an entire mobile network, can share one address, so limiting by IP throttles hundreds of innocent people because of one. Meanwhile an attacker who wants more attempts simply rotates through proxies. When you know the authenticated user, key on that instead. And note that your IP fallback is only as correct as your trust proxy setting (9.9.2) — get that wrong and every request shares one key.

(3) Send rejections through your error funnel. A 429 is a response your clients have to handle, so it should carry the same envelope, the same stable code, and the same trace ID as every other error (9.9.3). Include Retry-After so a well-behaved client knows when to come back instead of guessing.

Use tiers, not one global number. A single limit either throttles normal usage or protects nothing. Split it by what the endpoint is for.

Credential endpoints — login, password reset, one-time codes — get the strictest limits, applied both per account and per IP, because these are what gets attacked. Expensive endpoints — search, exports, report generation — get their own limits sized by what your infrastructure can absorb, since ten of those can hurt more than a thousand ordinary reads. Everything else gets a generous ceiling whose only job is catching a client stuck in a retry loop.

Finally, be clear about what this is not. Rate limiting slows down brute force and protects your capacity from runaway clients. It is not protection against a distributed denial of service attack, because by the time that traffic reaches your Node process you have already lost — that belongs at the CDN or edge (Part 13.4). And it is not an account lockout policy, which is a different control with different rules (Part 8.4.1).

4. Compression — and BREACH

app.use(compression()) gzips responses above a size threshold. For JSON APIs and HTML this is a straightforward win — less bandwidth, faster pages, one line of code. Three things are worth knowing before you leave it on everywhere.

Where it belongs. In production, compression usually belongs to your reverse proxy or CDN rather than to Node ([9.9.7]). Compressing is real CPU work, and in Node it runs on the thread pool (3.8.2), competing with the other work that pool does. A proxy in front of you does it without touching your process at all. Use the middleware when there is no proxy, or when you need per-response control.

It fights with streaming. Compression works by buffering output so it has something to compress. That is exactly wrong for Server-Sent Events or any long-lived stream, where the point is that each small message leaves immediately. Exclude those routes with the filter option, or flush explicitly, or you will debug a "live" feed that arrives in bursts every few seconds.

The security caveat, called BREACH. This one is worth understanding rather than memorising, because the reasoning is elegant and generalises.

Compression makes output smaller by finding repetition. So if a response contains a secret and also contains text the attacker controls, the size of the compressed response depends on how much those two things have in common. An attacker who can make your page reflect their input, one guess at a time, can watch the response size: when their guess matches the beginning of the secret, the response compresses slightly better and comes back a few bytes smaller. Repeat, and they read the secret one character at a time without ever seeing it.

The mitigations follow directly. Do not reflect user input into a response that also contains a secret. Keep tokens out of compressible bodies, or change them on every request so a slow guessing attack never gets a stable target. Turn compression off for any response that genuinely must contain both.

For a typical JSON API, where tokens travel in headers and responses do not echo user input, the practical risk here is low. Knowing why is what lets you decide that rather than guess it.

5. The expert lens

Not choosing is still choosing. Express ships with no security headers, no CORS policy, no rate limits, and no body size caps. Every one of those has to be an explicit act by you. That is the framework's philosophy (9.9.1) colliding with an uncomfortable fact: an absent security setting is not neutral, it is the insecure option, silently selected.

The practical answer at team scale is a shared hardening module that every service imports — one applySecurity(app, config) call that sets the baseline. A new service is then safe on its first line, and any deviation shows up as an argument someone had to pass, which is reviewable, rather than as a missing line that nobody notices.

Order is a security property, again. Helmet goes above everything that can respond, so your 404s and error responses also carry the headers. CORS goes above auth, because preflights carry no credentials. Rate limits go above the expensive identity work, so a flood is rejected before you verify a token and hit the database. Body size caps go above the parser, because after parsing the memory is already spent.

Every one of those mis-orderings fails quietly, and worse, fails quietly only in production. Your laptop has no proxy, makes no cross-origin requests, and is not being attacked, so the local run looks perfect in all four cases.

Know what each control does not do. CORS is not authorization. Rate limiting is not defence against a distributed attack. Helmet does not make you immune to XSS — output encoding does that (Part 8.5). Compression is not without consequence.

These are narrow, well-understood mitigations, and each one covers exactly its own ground. The team that treats them as a checklist ends up with the app described at the top of this page: helmet() present, 'unsafe-inline' in the policy, origin: true in the CORS config, and a security review that says everything is fine.

Next: 9.9.6 — the architecture Express refuses to give you: MVC, layers, repositories, services, dependency injection, and hexagonal design, each judged by the size of app that actually justifies it.

What the interviewer will push on

Security middleware questions are a fast way to tell whether someone configured these or inherited them.

"Why does this work in Postman but fail in the browser?" The answer is that CORS is enforced by the browser, not by your server. Then take it one step further, because that is where the real understanding shows: this means CORS is not access control at all, and anyone can still call your API with a script. It protects your users from other websites reading their data; it protects nothing from a direct caller.

"Your preflight requests are getting 401s. What's wrong?" Middleware order. An OPTIONS preflight carries no credentials by design, so an authentication middleware registered above CORS rejects it, and the browser reports a vague CORS error for what is really an ordering bug. Being able to say "the error message points at the wrong thing" is what makes this answer sound lived rather than read.

"What's wrong with origin: true and credentials: true together?" It tells every website that its JavaScript may call your API with your users' cookies and read the response. The specification actually forbids the wildcard with credentials, which is why people reach for origin reflection instead — and reflection is the same hole with extra steps. The answer they want is an allow-list from configuration.

"You have four replicas and a limit of 10 per minute. What's the real limit?" Forty, if the counter is in process memory. This is a small arithmetic question that quickly reveals whether someone has run a limiter in production. The follow-up worth volunteering is the key choice: identity where you have it, IP as a fallback, and an awareness that IP keys punish shared networks and are easy for an attacker to rotate.

"How would you introduce a Content Security Policy to an existing site?" In report-only mode first, with an endpoint collecting violations, because the real inventory of scripts, embeds, and inline handlers is always larger than anyone believes. Then tighten. Mentioning that 'unsafe-inline' defeats the whole point is the detail that separates a designed policy from a copied one.

Volunteer this one, because nobody asks: point out that mis-ordered security middleware fails silently and only in production, because a developer machine has no proxy in front of it, no cross-origin callers, and nobody attacking it. That is why these belong in a shared hardening module rather than being assembled per service — the failure mode is invisible exactly where you would notice it.

Recall

  • Helmet sets the header zoo: CSP (XSS — the one you must design: nonces or hashes, never 'unsafe-inline'; roll out with Report-Only), HSTS (downgrade; preload is one-way), nosniff (MIME confusion), frame-ancestors/X-Frame-Options (clickjacking), Referrer-Policy (URL/token leakage), cross-origin isolation headers, and removes X-Powered-By.
  • CORS is a browser mechanism and not authorization — Postman/curl/server-to-server ignore it. Preflight (OPTIONS) precedes non-simple requests and carries no credentials, so CORS must be registered before auth; cache with maxAge. Never origin: true with credentials: true — allow-list from config.
  • Rate limiting in Express: shared store (Redis) or replicas multiply the limit; key on identity first, IP fallback (NAT fairness; IP depends on trust proxy); route rejections through the error funnel (429 + Retry-After + envelope); tier limits (credentials strictest, expensive endpoints next, reads generous). It is not DDoS protection and not lockout policy.
  • compression: prefer the proxy/CDN in production (zlib rides the thread pool); exclude SSE/streams; BREACH — compression + reflected input + a secret in one response = a size oracle; don't mix them.
  • Lens: unset security is insecure (ship a shared hardening baseline module); order is security and mis-ordering fails silently in dev; know each control's non-guarantees.

Self-test: Name the attack each Helmet header blunts. Why does CORS "work in Postman," and what does that prove? Why must CORS precede auth? What multiplies your rate limit by four, and what's the right key? State BREACH in one sentence.

Quiz Bank

FoundationalWhat does Helmet do, and which of its headers matter most for a JSON API versus an HTML app?

Helmet sets a family of security response headers (and removes X-Powered-By). Universally valuableX-Content-Type-Options: nosniff (stops MIME-sniffing a response into executable script), Strict-Transport-Security (forces HTTPS for the duration, blocking SSL-strip downgrades; preload is effectively irreversible, so verify subdomain coverage first), Referrer-Policy (prevents leaking URLs — which often carry tokens or IDs — to third-party sites), and the cross-origin isolation headers.

HTML apps additionally need CSP and frame protection: CSP restricts which script/style/frame sources may load, converting a successful injection into a blocked resource; frame-ancestors (CSP) or X-Frame-Options prevents clickjacking. A pure JSON API gains less from CSP (no HTML surface to inject into) but should still send it defensively for error pages and any served docs — and the header cost is negligible. The design work is CSP alone: build it with nonces or hashes, avoid 'unsafe-inline' (which defeats it), and deploy first as Content-Security-Policy-Report-Only with a collection endpoint, because your real inventory of scripts, embeds, and analytics is always larger than the one in your head.

FoundationalExplain CORS: what it protects, why Postman is unaffected, and what preflight is.

The browser's same-origin policy prevents a page on origin A from reading responses fetched from origin B — protecting users' authenticated sessions from arbitrary sites (without it, any page you visit could read your bank's API responses using your cookies). CORS is the server's opt-in: response headers (Access-Control-Allow-Origin/-Credentials/-Headers/-Methods) tell the browser which cross-origin reads to permit.

Postman/curl/server-to-server are unaffected because the enforcement lives in the browser, not in your server — nothing about CORS restricts a non-browser client. Two corollaries: "it works in Postman" is not evidence of a server bug (it's expected), and CORS is not authorization — it never protects your API from direct clients, so authentication and permission checks remain entirely your middleware's responsibility (Part 8.5).

Preflight: for non-simple requests (methods beyond GET/POST/HEAD, custom headers such as Authorization or Idempotency-Key, unusual content types), the browser first issues an OPTIONS request to ask permission; the real request follows only if allowed. Because that OPTIONS carries no credentials, any auth middleware registered above CORS will reject it, and the browser will surface an opaque CORS failure for what is actually a middleware-ordering bug (9.9.1).

AppliedWire rate limiting for an API with login, search, and ordinary reads. Specify stores, keys, tiers, and the response contract.

Store: Redis-backed for every limiter — the default memory store is per-process, so N replicas silently permit N× the limit (9.7.5, 3.8.6). Key: req.user?.id ?? req.ip — authenticated identity first (fair, precise, un-rotatable), IP only pre-auth; and IP is only meaningful if trust proxy matches the topology (9.9.2), otherwise every client shares the proxy's bucket.

Tiers: login/password-reset/OTP — strict and dual-keyed (per-account and per-IP: per-account stops credential stuffing against one user, per-IP stops spraying across many; e.g. 10 per 15 min per account, 50 per 15 min per IP) — plus account lockout as a separate control (Part 8.4.1: rate limiting slows attackers, lockout stops them).

Search/export/report — expensive endpoints get their own modest budget, often token-bucket shaped to allow bursts (9.7.5). Ordinary reads — a generous ceiling whose purpose is catching runaway clients, not policing users.

Response contract: 429 emitted through the error funnel so it carries the standard envelope, a stable code (RATE_LIMITED), the traceId, and Retry-After; send RateLimit-* headers on successful responses too so well-behaved clients can self-pace (9.6.1). Observability: rejection rate per tier as a metric with alerting on deltas — a sudden 100% rejection rate is nearly always a config or key-derivation bug, not an attack (9.7.5's ops note).

InterviewWhat is BREACH, and how does it change how you configure compression?

BREACH is a class of attack exploiting the fact that compressed size leaks information about content. If a single response contains both a secret (a CSRF token, a session identifier, an API key echoed in a page) and attacker-controlled reflected input, an attacker can vary their input and observe the compressed response length: guesses that match the secret's bytes compress better, so response size becomes an oracle that extracts the secret character by character across many requests.

Configuration consequences: don't compress responses that mix reflected user input with secrets; keep secrets out of compressible bodies (headers are not compressed by HTTP/1.1 gzip; HTTP/2's HPACK is a separate, mitigated case); rotate CSRF tokens per request so an extracted value is useless; and disable compression selectively on affected routes rather than globally.

The practical calibration: a JSON API that never reflects user input into responses containing secrets carries little exposure — but a server-rendered page echoing a search term next to a CSRF token is the textbook target. Two adjacent compression decisions worth stating in the same breath: prefer compressing at the reverse proxy/CDN (zlib work rides Node's thread pool otherwise — 3.8.2), and exclude streaming endpoints (SSE) where buffering breaks the streaming contract.

StaffYou own platform engineering for 25 Express services with inconsistent security posture — some have Helmet, two have `origin: true` with credentials, rate limits are per-process, and nobody can say which services enforce what. Design the remediation and the mechanism that keeps it true.

Mechanism first, audit second — the reverse is a report nobody acts on. (1) Ship a hardening baseline package (@org/express-baseline) exporting one function: applyBaseline(app, { origins, limits, trustProxyHops, csp }). It installs, in the standard order (9.9.1): Helmet with an org CSP profile, CORS from an explicit allow-list (the function refuses true — the origin: true + credentials: true combination is rejected at startup with a fatal error, converting the worst misconfiguration from a silent vulnerability into a boot failure), trust proxy from config, body-size limits, the traceId/context middleware, Redis-backed rate limiting with the standard tiers and key derivation, and the error funnel (9.9.3) that guarantees the envelope. Everything is overridable by argument — deviations become visible in code review instead of invisible omissions. (2)

Make posture observable — a /internal/security-posture endpoint (auth-gated) reporting the effective configuration, harvested centrally so "which services enforce what" is a dashboard, not an archaeology project; add a boot-time log line with the same summary so it's greppable per deploy. (3)

Verify externally, not just internally — a scheduled scanner hitting every service's public endpoints asserting the header set, the CORS behavior for a hostile origin (a request from evil.example must not receive Access-Control-Allow-Origin), and rate-limit enforcement across replicas (fire N+1 requests, expect a 429 — this is the test that catches per-process stores, which no code review reliably catches). (4)

Migrate by risk, not alphabetically — the two origin: true services first (they are actively exploitable), then anything handling credentials or money, then the rest; each migration is a one-line adoption of the baseline plus config, so the diff is reviewable in minutes. (5)

Keep it true: the baseline package version becomes a release-gate check (services below the floor fail CI), the scanner's findings page the owning team, and new-service templates import it by default. The org sentence: security posture is either a property of a shared, tested, observable module — or it is twenty-five separate promises, and promises are not controls.

Flashcards

FlashHelmet's big four

CSP (XSS — design it: nonces/hashes, never unsafe-inline) · HSTS (downgrade; preload one-way) · nosniff (MIME confusion) · frame-ancestors (clickjacking).

FlashCORS truths

Browser-only mechanism; not authorization; preflight OPTIONS carries no credentials ⇒ register CORS before auth; never origin:true with credentials:true.

FlashRate limiting in Express

Redis store (per-process = N× limit) · key on identity, IP fallback (trust proxy!) · 429 via the error funnel + Retry-After · tier: credentials strict, expensive next, reads generous.

FlashWhat these controls are NOT

CORS ≠ authorization. Rate limiting ≠ DDoS defense (edge/CDN) and ≠ lockout policy. Helmet ≠ XSS immunity (output encoding is).

FlashBREACH

Compression + reflected input + a secret in one response = size oracle extracting the secret. Don't mix; rotate CSRF tokens; exclude affected routes.

Scenario Drill

DrillA pentest report on your Express API lands with five findings: (1) no CSP on any response, (2) Access-Control-Allow-Origin reflects any Origin with credentials enabled, (3) login endpoint has no rate limit, (4) 500 responses include stack traces, (5) X-Powered-By reveals Express and the version. Triage by real risk, fix each with the correct mechanism, and identify which finding is actually two bugs.

Triage by exploitability, not by report order. (2) is critical and immediately exploitable: reflecting any Origin with credentials: true means any website a logged-in user visits can issue authenticated cross-origin requests and read the responses — full account takeover-grade data exfiltration, no user interaction beyond visiting a page. Fix: an explicit allow-list from config, with a startup assertion that rejects wildcard-plus-credentials ([9.9.5]'s baseline refusing the combination). This is also the finding to verify with an external test (a request bearing Origin: https://evil.example must receive no Access-Control-Allow-Origin), because the config and the effective behavior can diverge behind a proxy.

(3) is high and actively abused in the wild: an unlimited login endpoint invites credential stuffing at whatever rate the attacker's botnet supports. Fix: dual-keyed limiter (per-account and per-IP), Redis-backed for replica correctness, plus — the part the report won't say — account lockout/step-up is a separate control (Part 8.4.1): limiting slows an attacker; it does not stop a slow, distributed one.

(4) is high, and it is actually two bugs: the visible one is information disclosure (stack traces reveal file paths, dependency versions, and sometimes query fragments — a map for the next attack); the hidden one is that stack traces reaching clients means there is no centralized error funnel (9.9.3) — so error shapes are inconsistent, logging is scattered or absent, and the same defect will recur wherever the next handler is written. Fix both: install the funnel (opaque 5xx bodies with a traceId, full context to logs) rather than patching the one leaking route, and assert it with the leak test from 9.9.3's drill.

(1) is medium and conditional: for a pure JSON API, CSP prevents little directly (no HTML surface), but it costs nothing and matters for any served docs, error pages, or future HTML; deploy Report-Only first, collect for a week, then enforce. Combine with the rest of the Helmet set, which is free.

(5) is low but free: X-Powered-By removal is one line (Helmet does it) — real value is denying trivial version fingerprinting for mass-scanning attackers, not defense in depth against a targeted one. The closing observation for the report response: four of the five findings are missing defaults — no CSP, no allow-list, no limiter, no funnel — which is [9.9.5]'s thesis in evidence: Express hands you a secure-by-nothing baseline, so a service's security posture equals the set of decisions someone deliberately made; the durable remediation is a shared baseline module plus an external scanner, not five patches.