Appearance
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]
| Header | Defends against | Note |
|---|---|---|
| Content-Security-Policy | XSS (script injection) | The big one; needs per-app tuning (sectionbelow) |
| Strict-Transport-Security (HSTS) | SSL-stripping / downgrade | Browser refuses HTTP for maxAge; preload is one-way — verify before enabling |
| X-Content-Type-Options: nosniff | MIME confusion (a .txt executed as JS) | Free; always on |
| X-Frame-Options / frame-ancestors | Clickjacking (your UI in a hostile iframe) | CSP's frame-ancestors supersedes it |
Referrer-Policy | Leaking URLs (with tokens) to third parties | no-referrer or strict-origin-when-cross-origin |
X-DNS-Prefetch-Control, X-Permitted-Cross-Domain-Policies | Minor info leaks / legacy plugins | Free hygiene |
Origin-Agent-Cluster, Cross-Origin-*-Policy | Cross-origin isolation (Spectre-class — 1.5) | Can break embeds; test |
(removes) X-Powered-By | Fingerprinting | Helmet 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 security headers, each against one attack:
- CSP stops XSS. It is the one you must design: use nonces or hashes, never
'unsafe-inline', and roll it out withReport-Onlyfirst. - HSTS stops downgrade to plain HTTP. Joining the preload list cannot easily be undone.
nosniffstops the browser guessing a file's type.frame-ancestorsand X-Frame-Options stop clickjacking.Referrer-Policystops URLs and tokens leaking to other sites.- It also sets cross-origin isolation headers and removes
X-Powered-By.
- CSP stops XSS. It is the one you must design: use nonces or hashes, never
- 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 withmaxAge. Neverorigin: truewithcredentials: true— allow-list from config. - Rate limiting in Express:
- Use a shared store such as Redis. Without it, every replica keeps its own count, and the real limit is multiplied by the number of replicas.
- Count by user identity first, and by IP only when there is no user. Many people can share one IP, and the IP is only correct if
trust proxyis set. - Send rejections through the error funnel:
429, aRetry-Afterheader and the standard error body. - Use tiers. Login and password endpoints get the strictest limits, expensive endpoints come next, and reads get generous limits.
- It is not DDoS protection, and it is not a 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). Useful everywhere:
X-Content-Type-Options: nosniffstops the browser guessing that a response is a script and running it.Strict-Transport-Securityforces HTTPS for a set time, which blocks attacks that downgrade the connection to plain HTTP. Itspreloadoption is almost impossible to undo, so check that every subdomain supports HTTPS first.Referrer-Policystops URLs leaking to other sites. URLs often contain tokens or IDs.- 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 and OTP get strict limits, counted two ways at once. The count per account stops someone trying many passwords against one user. The count per IP stops someone trying one password against many users. For example: 10 attempts per 15 minutes per account, and 50 per 15 minutes per IP. Account lockout is a separate control on top (Part 8.4.1). Rate limiting slows attackers down, and 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.
What it means for your settings:
- Do not compress responses that contain both something the user sent and a secret.
- Keep secrets out of bodies that get compressed. HTTP/1.1 gzip does not compress headers. HTTP/2's header compression, HPACK, is a separate case that already has defences.
- Change the CSRF token on every request, so a stolen value is useless.
- Switch compression off only on the affected routes, not everywhere.
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.
Build the mechanism first and audit second. The other way round gives you a report nobody acts on.
(1) Ship a hardening baseline package, @org/express-baseline, with one function: applyBaseline(app, { origins, limits, trustProxyHops, csp }). It installs these, in the standard order from 9.9.1:
- Helmet, with the company's CSP profile.
- CORS from an explicit list of allowed origins. The function refuses
true. If someone passesorigin: truetogether withcredentials: true, the service refuses to start. The worst CORS mistake stops being a silent hole and becomes a startup error. trust proxy, read from config.- Body-size limits.
- The traceId and request-context middleware.
- Rate limiting backed by Redis, with the standard tiers and keys.
- The error funnel from 9.9.3, which guarantees the standard error body.
Any of these can be changed by passing an argument. That way, a service that differs from the baseline shows the difference in code review, instead of silently leaving something out. (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)
Check from the outside too, not only from inside. Run a scanner on a schedule against every service's public endpoints. It checks three things:
- The security headers are all there.
- CORS behaves correctly for a hostile origin. A request from
evil.examplemust not getAccess-Control-Allow-Originback. - Rate limits hold across all replicas. Send N+1 requests and expect a
429. This is the check that catches a limiter whose counts live in each process separately, which code review rarely 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 attackers really do this. A login endpoint with no limit lets attackers try stolen passwords as fast as their botnet can go. The fix is a limiter that counts both per account and per IP, backed by Redis so it is correct across replicas. And here is the part the report will not tell you: locking the account, or asking for an extra check, is a separate control (Part 8.4.1). Rate limiting slows an attacker down. It does not stop one who goes slowly from many machines.
(4) is high, and it is really two bugs. The visible bug is leaking information. Stack traces show file paths, library versions and sometimes parts of queries. That is a map for the next attacker. The hidden bug is what the leak tells you: if stack traces reach clients, there is no central error funnel (9.9.3). So error shapes differ from route to route, logging is patchy or missing, and the same leak will come back in the next handler someone writes. Fix both at once. Install the funnel, which sends a plain 5xx body with a traceId to the client and the full details to the logs. Do not just patch the one route that leaked.
(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 point for the reply to the report: four of the five findings are missing defaults. There was no CSP, no allow-list, no rate limiter and no error funnel. That is the whole point of this page in action. Express gives you no security by default. A service is exactly as secure as the decisions someone made on purpose. The lasting fix is a shared baseline module plus an outside scanner, not five separate patches.