
Helmet is how Express writes a bundle of security headers. It is not authorization and it is not a sanitizer.
One line, app.use(helmet()), will emit a policy. That policy still includes defaults you may not want, including inline styles. Read the output with curl -D - before you call the app hardened.
The usual mistake is a Helmet badge on a README and an API that returns any invoice id. Headers never see that id.
This page is what Helmet sets in 2026, what to override, and the two jobs you still have to do in the handler.
helmet 8.3.0 published on 12 July 2026. As of the current docs, the Helmet docs. The stock policy still ends by allowing inline CSS. Teams paste app.use(helmet()) on Express 5 and call XSS closed. A nonce minted once at process start is the other miss: every HTML response shares it, and a CDN will cache it.
The control is a header set you chose, a nonce that changes on every document, and a template that never concatenates untrusted HTML. Keep the HTTP headers guide next to this page. Read XSS for the sink, and CSRF when the browser is the one attaching the cookie.
Helmet is a header pack, not a sink fix
That release sets 13 response headers when you call app.use(helmet()). I copied the list from helmetjs.github.io on 22 August 2026:
Content-Security-Policywith the stock directives belowCross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-originOrigin-Agent-Cluster: ?1Referrer-Policy: no-referrerStrict-Transport-Security: max-age=31536000; includeSubDomainsX-Content-Type-Options: nosniffX-DNS-Prefetch-Control: offX-Download-Options: noopenX-Frame-Options: SAMEORIGINX-Permitted-Cross-Domain-Policies: noneX-XSS-Protection: 0(the legacy filter, turned off on purpose)X-Powered-Byremoved if Express set it
None of those lines read the body. res.send, res.render, and a React SSR string still decide what the browser parses as HTML. CWE-79 lives in that string. A header can refuse a script the page already emitted. It cannot unsay the emission.
HEADER Content-Security-Policy: script-src 'self' 'nonce-abc123'
browser refuses a script tag that lacks that nonce
SINK res.send("<div>" + req.query.q + "</div>")
the header never rewrites this string
FIX a template escape, or a nonce only on scripts you authored
Helmet sets the first line. You still own the second.
That split is the whole page. Treat Helmet as the header pack in the secure coding checklist. Treat the template as the XSS job.
The default CSP is not a policy
The docs print the stock header like this:
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
Helmet’s own sentence is the warning: “This header is powerful but likely requires some configuration for your specific app.” script-src 'self' blocks your inline bootstrap and does not mint a nonce for it. style-src still allows inline CSS from any HTTPS origin. upgrade-insecure-requests will push Safari from http://localhost to https://localhost. That is a starter pack, not a review you can point a ticket at.
Directives you pass in are merged onto that default. Set useDefaults: false only when you are ready to write every line. helmet.contentSecurityPolicy.getDefaultDirectives() is the object to print in a test so a silent upstream change shows up.
| Directive | Stock Helmet 8.3.0 | What you still decide |
|---|---|---|
script-src | 'self' | Nonce or hashes for every inline script you keep |
script-src-attr | 'none' | Leave it. Inline onclick is a sink |
style-src | self, https, plus inline CSS | Hashes or a nonce. Drop the inline token |
connect-src | inherits default-src | Your API origin, nothing else |
frame-ancestors | 'self' | Keep it. X-Frame-Options is the leftover |
Mint the nonce on the response
Helmet will call a function in a directive array with req and res. That is the supported hook. The miss is a string you built once:
// BAD: one nonce for the life of the process
const { randomBytes } = require("node:crypto");
const cachedNonce = randomBytes(32).toString("hex");
const scriptSrc = ["'self'", `'nonce-${cachedNonce}'`];
// then handed to Helmet once at boot, same value on every response
cachedNonce is the name to grep. If it exists at module scope, every document shares it. A CDN that caches the HTML caches the permission.
The replacement, matching the Helmet example, is a value on res.locals created inside the request:
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.use(assignCspNonce);
app.use(
helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", nonceSrc],
styleSrc: ["'self'", nonceSrc],
},
},
}),
);
app.get("/", (req, res) => {
res.render("home", { cspNonce: res.locals.cspNonce });
});
The template must emit the same cspNonce on every script and style you intend to keep:
<script nonce="<%= cspNonce %>" src="/assets/app.js"></script>
<script nonce="<%= cspNonce %>">
window.__BOOT__ = true;
</script>
Two rules travel with that name. Do not put cspNonce into a static file on disk. Do not serve the document with a long max-age. HTML that carries a nonce is a one-time envelope. Cache-Control: no-store on that response is the cheap lock. Hash static files separately if you want a CDN on JS and CSS.
Drop unsafe-inline, including next to a nonce
The Stack Overflow thread this page is allowed to quote is still the same miss in 2026. james emanon’s app sent both a nonce and 'unsafe-inline', plus a pile of third-party hosts, and the scripts still refused to load.
CSP3 browsers ignore 'unsafe-inline' for scripts when a nonce or a hash is present. That is not a reason to leave the token in the header. It teaches the next editor that inline is allowed. It also fails open on an old client that never learned the nonce rule. Write scriptSrc as 'self' plus the cspNonce function. Do not add 'unsafe-eval' to paper over a bundler. If a vendor script cannot take a nonce, give it a hash you computed, or do not load it.
Styles are the leftover. Helmet’s stock style-src still includes 'unsafe-inline'. Override it the same way you override scripts. A CSS-in-JS runtime that injects tags will break until those tags carry cspNonce. That breakage is the point.
COOP, COEP, and OAC only if you need isolation
Helmet 8 turns on three isolation-adjacent headers and leaves the fourth off.
Cross-Origin-Opener-Policy: same-originis on. It severswindow.opener. An OAuth popup that reads the opener will fail. If you need that popup, setsame-origin-allow-popups. If you do not, keep the default.Cross-Origin-Embedder-Policyis off. Helmet will not send it until you passcrossOriginEmbedderPolicy: true. That header is how you opt intocrossOriginIsolatedandSharedArrayBuffer. It also requires CORP or CORS on every cross-origin asset. Do not flip it because a checklist named COEP. Flip it when a first-party feature needs isolation, then load every font and script with the matching CORS headers.Origin-Agent-Cluster: ?1is on. It asks the browser to process-isolate this origin. Leave it unless a vendor tells you it broke a shared worker you actually use.Cross-Origin-Resource-Policy: same-originis on. A CDN host or a font file loaded from another origin will stop. Setsame-siteor turn the header off for the asset app, not for the HTML app, if you meant to share those files.
app.use(
helmet({
crossOriginOpenerPolicy: { policy: "same-origin-allow-popups" },
crossOriginEmbedderPolicy: false,
originAgentCluster: true,
crossOriginResourcePolicy: { policy: "same-origin" },
}),
);
That block is explicit. COEP stays false. COOP is loosened only for the popup you can name. OAC stays on. CORP stays origin-bound on the document origin.
Express 5 does not change the job
Express 5.1 still takes Connect-style middleware. app.use(helmet()) is the same call as on Express 4. The bump did change two nearby defaults that people blame on Helmet.
The Express 5 migration guide makes req.query a getter and switches the query parser from extended to simple. That is an injection-surface change, not a header change. It does not alter CSP. It does mean a security middleware that used to assign onto req.query will throw. Helmet does not do that.
Express still sets X-Powered-By: Express unless you disable it. Helmet removes the header later in the stack. Prefer the native switch so a response that never reached Helmet is already quiet:
const express = require("express");
const helmet = require("helmet");
const app = express();
app.disable("x-powered-by");
app.use(assignCspNonce);
const csp = helmet.contentSecurityPolicy.getDefaultDirectives();
if (app.get("env") === "development") {
csp["upgrade-insecure-requests"] = null;
}
csp["script-src"] = ["'self'", nonceSrc];
csp["style-src"] = ["'self'", nonceSrc];
app.use(helmet({
contentSecurityPolicy: { useDefaults: false, directives: csp },
crossOriginEmbedderPolicy: false,
}));
upgradeInsecureRequests set to null in development is the Helmet-documented escape for Safari on localhost. Do not copy that null into production.
HSTS is on by default with a one-year max-age and includeSubDomains. That is correct behind HTTPS. It is painful on a shared localhost if you also open other apps. Disable strictTransportSecurity in development only.
Prove the header and the nonce
You are not walking an exploit. You are proving two responses disagree on the nonce, and that the document header names the same value the HTML uses.
- Hit your own origin twice with
curl. Copy theContent-Security-Policyline. - Extract the
'nonce-...'token from each response. They must differ. - If they match, you cached
cspNonceor you cached the HTML. - View source on the same path. Every
<script>you intend to keep must carry that response’s nonce.
curl -sS -D - -o /tmp/h1.html "https://your-app.example/" | sed -n 's/.*nonce-\([a-f0-9]*\).*/\1/p'
curl -sS -D - -o /tmp/h2.html "https://your-app.example/" | sed -n 's/.*nonce-\([a-f0-9]*\).*/\1/p'
# Expect: two different hex strings
# Then: grep the nonce from h1.html inside the body of h1.html
Grep the repo for the footguns this page named:
rg -n "unsafe-inline|cachedNonce|contentSecurityPolicy:\\s*false|crossOriginEmbedderPolicy:\\s*true" --glob '!node_modules'
A hit on contentSecurityPolicy: false is a review, not a lecture. Some JSON APIs should not send CSP. An HTML route must.
Questions we keep getting
Does Helmet 8 stop XSS on Express 5?
It sets a CSP and turns off the old XSS filter. It does not escape res.send. If user text reaches HTML, you still have CWE-79. Use the template engine’s escape, and put cspNonce only on scripts you wrote.
Should I turn on COEP because Helmet offers it?
No. Helmet leaves Cross-Origin-Embedder-Policy unset. Enable it when you need crossOriginIsolated or SharedArrayBuffer, then fix every cross-origin asset. A login page does not need it.
Is the stock CSP enough for a single-page app?
No. A SPA still needs a per-response nonce or hashes for the bootstrap, a tight connect-src, and no 'unsafe-inline' on scripts. The stock style-src line is the first thing to override.



