Get listed

Helmet.js: a header pack, not an XSS fix

A leather bike helmet with the chin strap unbuckled in coral.

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-Policy with the stock directives below
  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Resource-Policy: same-origin
  • Origin-Agent-Cluster: ?1
  • Referrer-Policy: no-referrer
  • Strict-Transport-Security: max-age=31536000; includeSubDomains
  • X-Content-Type-Options: nosniff
  • X-DNS-Prefetch-Control: off
  • X-Download-Options: noopen
  • X-Frame-Options: SAMEORIGIN
  • X-Permitted-Cross-Domain-Policies: none
  • X-XSS-Protection: 0 (the legacy filter, turned off on purpose)
  • X-Powered-By removed 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.

The header names scripts the browser may run. The sink is the HTML you already wrote.
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.

DirectiveStock Helmet 8.3.0What 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-srcself, https, plus inline CSSHashes or a nonce. Drop the inline token
connect-srcinherits default-srcYour 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-origin is on. It severs window.opener. An OAuth popup that reads the opener will fail. If you need that popup, set same-origin-allow-popups. If you do not, keep the default.
  • Cross-Origin-Embedder-Policy is off. Helmet will not send it until you pass crossOriginEmbedderPolicy: true. That header is how you opt into crossOriginIsolated and SharedArrayBuffer. 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: ?1 is 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-origin is on. A CDN host or a font file loaded from another origin will stop. Set same-site or 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.

  1. Hit your own origin twice with curl. Copy the Content-Security-Policy line.
  2. Extract the 'nonce-...' token from each response. They must differ.
  3. If they match, you cached cspNonce or you cached the HTML.
  4. 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.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.