
Security headers are promises the browser will try to keep. They never see the invoice id.
CSP, HSTS, frame-ancestors, and COOP can contain XSS, stop a later plaintext visit, and block a foreign iframe. They cannot decide whether user A may read invoice 1842. That check is in your query.
The usual mistake is app.use(helmet()) and a closed IDOR ticket. Helmet writes headers. The stock policy still allows patterns you may not want. Read what it emits.
This page is the pack worth shipping in 2026, the Helmet defaults to override, and the two jobs headers will never do.
helmet 8.3.0 published on 12 July 2026. As of the current docs, helmetjs.github.io. The stock policy still ends with style-src 'self' https: 'unsafe-inline'. Thirteen response headers. Teams paste app.use(helmet()) and call IDOR closed. The browser never sees req.params.id.
The control is a pack you can print from curl -D -, a nonce that changes on every document, and the two jobs headers cannot do. Object authz lives on the IDOR guide. Interpreters live on the injection guide. The HTML sink lives on the XSS guide.
Helmet can print thirteen headers. None of them read req.params.id, so CSP, HSTS, and Trusted Types still leave IDOR and injection to the handler.
SecureCoding
Headers never see the invoice id
A response header is a browser instruction. CSP names which scripts may run. HSTS names which scheme the host must use. COOP names whether window.opener survives. None of those lines read the body of a GET for /invoices/1842. None of them bind a query parameter. CWE-639 and CWE-89 live in the handler.
Keep that split for the rest of the page. The pack below is what I would ship on an HTML origin in August 2026. A JSON API can drop CSP and Trusted Types. It still wants HSTS, COOP if it serves a document, and a Referrer-Policy if it ever renders one.
CSP without unsafe-inline
I copied the stock Helmet 8.3.0 header from the docs on 22 August 2026:
Content-Security-Policy:
default-src 'self';
base-uri 'self';
font-src 'self' https: data:;
form-action 'self';
frame-ancestors 'self';
img-src 'self' data:;
object-src 'none';
script-src 'self';
script-src-attr 'none';
style-src 'self' https: 'unsafe-inline';
upgrade-insecure-requests
Two problems sit in that starter. script-src 'self' blocks the inline bootstrap most SPAs still emit, and it does not mint a nonce. style-src still allows inline CSS from any HTTPS origin. Helmet’s own sentence is the warning: the header is powerful but likely needs configuration for your app.
The 2026 rule is narrower. Scripts you authored get 'self' plus a nonce created on the response. Styles get the same nonce. Drop 'unsafe-inline' from both. Drop 'unsafe-eval'. CSP3 browsers ignore the inline token for scripts when a nonce is present. Leaving it in the header teaches the next editor that inline is allowed, and it fails open on a client that never learned the nonce rule.
Mint cspNonce inside the request. A value built at process start is shared by every document and by every CDN cache of that document. The Express path for that hook is on the Helmet guide. This page keeps the identifier so the snippets agree: res.locals.cspNonce, then 'nonce-${res.locals.cspNonce}' in the header, then the same hex on every <script> and <style> you intend to keep.
Hashes are the other legal source for a file you cannot nonce. Compute the hash of the exact bytes. A bundler that changes one character needs a new hash. Do not paper over that with 'unsafe-inline'.
Trusted Types after Firefox 148
MDN marks Trusted Types Baseline newly available since February 2026. Chromium has shipped the API since 83. Safari 26 shipped it. Firefox 148, 24 February 2026, turned it on by default. The Mozilla Hacks setHTML note is the same week as the HN thread above.
The directive is one token:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
innerHTML, document.write, and the other DOM sinks then reject a string. They accept a TrustedHTML value created by a policy you registered. trusted-types default is the allowlist of policy names. A third-party script that calls trustedTypes.createPolicy("gtm") will throw until you add that name on purpose.
The policy that matches a page with no HTML sink is a refusal:
const htmlPolicy = trustedTypes.createPolicy("default", {
createHTML: () => {
throw new Error("no HTML sink");
},
});
If a widget must write HTML, name a second policy, list it in trusted-types, and put the sanitizer only in that function. Do not invent a “sanitize” helper that concatenates tags. The XSS guide is the sink page. This header only forces the write through a function whose name you can grep.
A JSON API should not send these two directives. There is no DOM. An HTML document should. Report-Only for a week if you have old widgets. Then enforce.
HSTS max-age, preload only when ready
Stock HSTS from that Helmet release:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That is 365 days. Chrome 8.0.0 raised it from 180. Helmet changelog for 8.0.0. includeSubDomains is on. preload is off. Leave preload off until every hostname on the registrable domain answers HTTPS, including the ones you forgot, and you have submitted the host at hstspreload.org. The preload list is compiled into browsers. Removing yourself is slow. A staging hostname that still speaks HTTP will brick if you preload the parent too early.
Do not send HSTS on plain HTTP. The browser ignores it, and a captive portal can lie. Send it only after TLS terminates. Disable the header in local development. Helmet will otherwise push some browsers from http://localhost to https://localhost, which collides with the next app on the same name.
upgrade-insecure-requests in CSP is a mixed-content helper, not a substitute for HSTS. First-visit HTTP still happens without preload. HSTS is what stops the second visit from being downgraded.
Who may embed this document
Helmet still sends both:
Content-Security-Policy:... frame-ancestors 'self'...
X-Frame-Options: SAMEORIGIN
MDN treats X-Frame-Options as the leftover. frame-ancestors is the CSP directive that names who may embed you. 'self' allows your own origin. 'none' allows nobody. An allowlist of exact origins is the third shape, for a named admin shell that frames the app.
For an app that should never sit in a foreign page, set both to deny:
frame-ancestors 'none'
X-Frame-Options: DENY
Do not rely on X-Frame-Options alone. It cannot name a third origin. ALLOW-FROM is dead. Helmet will not emit it. If a partner must frame you, list that origin in frame-ancestors and drop X-Frame-Options, or the leftover header will disagree with the directive on some clients.
COOP and Referrer-Policy
Helmet default COOP is same-origin. That severs window.opener when the other document is a different origin. It is the cheap process isolation you want on a login origin. An OAuth popup that reads the opener needs same-origin-allow-popups. Name the popup. Do not loosen COOP because a checklist said “compatible.”
Helmet does not send Cross-Origin-Embedder-Policy unless you pass crossOriginEmbedderPolicy: true. Leave it off unless you need crossOriginIsolated or SharedArrayBuffer. COEP plus CORP is a separate isolation project. It is not part of this pack.
Helmet default Referrer-Policy is no-referrer. That is the right default for a cookie session. Query strings in the Referer are how password-reset tokens and object ids leak to a third-party script host, a CDN, and your error tracker. If a partner must see that the request came from your origin on HTTPS navigations, use strict-origin-when-cross-origin. Do not use unsafe-url.
Permissions-Policy, which Helmet still skips
Evan Hahn declined to add Permissions-Policy because the spec was still a draft and Firefox and Safari did not ship it. The 8.3.0 docs still omit the header. MDN now documents it as the replacement for Feature-Policy. Set it next to Helmet. Do not wait for a middleware option that is not there.
Empty parentheses mean no origin, including you. That is the deny. A map origin that must use geolocation gets geolocation=(self). A payment iframe gets an explicit origin, not *. I have not confirmed every directive name against a 22 August 2026 Chrome first-party matrix. Camera, mic, geo, payment, and usb are the five I would lock on an account app that does not use those APIs. Add display-capture and clipboard-write if you do not need them either. The proxyHeaders block below is the concrete line.
The named fallback, if Helmet is not in the process, is proxyHeaders on the reverse proxy. Values must match the Express block. Do not invent a second allowlist.
# proxyHeaders: same pack the Express block sets
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Referrer-Policy "no-referrer" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Helmet is the Express path
On Express 5 the pack is one middleware plus the Permissions-Policy line Helmet will not write. Identifiers stay cspNonce, assignCspNonce, and nonceSrc so they match the Helmet page.
const { randomBytes } = require("node:crypto");
const helmet = require("helmet");
function assignCspNonce(req, res, next) {
res.locals.cspNonce = randomBytes(32).toString("hex");
res.set("Cache-Control", "no-store");
next();
}
function nonceSrc(req, res) {
return `'nonce-${res.locals.cspNonce}'`;
}
app.disable("x-powered-by");
app.use(assignCspNonce);
app.use(
helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", nonceSrc],
styleSrc: ["'self'", nonceSrc],
frameAncestors: ["'none'"],
requireTrustedTypesFor: ["'script'"],
trustedTypes: ["default"],
},
},
strictTransportSecurity: {
maxAge: 31536000,
includeSubDomains: true,
preload: false,
},
crossOriginOpenerPolicy: { policy: "same-origin" },
referrerPolicy: { policy: "no-referrer" },
xFrameOptions: { action: "deny" },
crossOriginEmbedderPolicy: false,
}),
);
const featureDeny = ["camera", "microphone", "geolocation", "payment", "usb"]
.map((n) => `${n}=()`)
.join(", ");
app.use((req, res, next) => {
res.set("Permissions-Policy", featureDeny);
next();
});
That is the Express call. Nonce details, the stock-policy table, and the COEP warning stay on the Helmet guide. Do not set contentSecurityPolicy: false on an HTML route to silence a console error. Fix the directive. A JSON-only service may disable CSP. Say so in the review, not by deleting the middleware for the whole app.
| Header | 2026 value | Stock Helmet 8 |
|---|---|---|
| CSP scripts | 'self' plus per-response nonce, no inline token | script-src 'self', styles still inline |
| Trusted Types | script sinks, policy name default | unset |
| HSTS | one year, subdomains, preload off until submitted | same, preload off |
| frame-ancestors | 'none' unless you name a framer | 'self' plus XFO SAMEORIGIN |
| COOP | same-origin | same-origin |
| Referrer-Policy | blank Referer | blank Referer |
| Permissions-Policy | featureDeny list in the Express block | unset |
Prove the pack on your origin
You are not walking an exploit. You are proving the document response carries the pack, the two GETs disagree on the nonce, and a JSON route you care about did not lose HSTS.
- Hit your own HTML origin twice with
curl -sS -D -. - Copy the CSP line. Extract each
'nonce-...'. They must differ. - Confirm Trusted Types,
frame-ancestors, HSTS, COOP, blank Referer, and Permissions-Policy. - View source. Every script you intend to keep must carry that response’s nonce.
curl -sS -D - -o /tmp/h1.html "https://your-app.example/"
curl -sS -D - -o /tmp/h2.html "https://your-app.example/"
# Expect: two different nonce- tokens
# Expect: trusted-types-for and a nonce that changes
# Expect: Strict-Transport-Security with max-age=31536000
# Expect: Permissions-Policy on the HTML response
Then grep the repo for the footguns this page named:
rg -n "unsafe-inline|unsafe-eval|contentSecurityPolicy:\\s*false|preload:\\s*true" --glob '!node_modules'
A hit on preload: true is a review of the subdomain list, not a lecture. A hit on unsafe-inline is a ticket. Headers still do not close IDOR or injection. After the pack is green, open those two pages and run their tests.
Questions we keep getting
Does a tight CSP replace a template escape?
No. CSP can refuse a script the page already emitted. It cannot unsay res.send that concatenated user HTML. Escape in the template. Put cspNonce only on scripts you wrote. That is the XSS job.
Should every API response send CSP and Trusted Types?
No. Those two exist for a document the browser will parse as HTML. A JSON handler can skip them. Keep HSTS on the HTTPS origin. Keep object authz on every id. Keep parameterized SQL.
When do I add preload to HSTS?
After every hostname on the registrable domain is HTTPS, including forgotten ones, and after you submit the host at hstspreload.org. Helmet leaves preload off. That is the safe default. Do not flip it to decorate a scan.



