Skip to content

What helmet actually sets

Severity: mediumApplies to: helmet 8.xFacts last verified 2026-08-13 against helmet 8.3.0

Express sets no security headers. helmet is how almost everyone adds them, usually as one line, usually without reading what that line does. Here is the full default set, read from helmet’s source:

Header Default value
Content-Security-Policy a real policy — see 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
X-Powered-By removed
the fix
import helmet from "helmet";
app.use(helmet());
verify it workedRun this in: http response
Terminal window
curl -sI https://example.com/ | grep -iE 'content-security-policy|strict-transport|x-frame|x-content-type|referrer-policy'
settings on this page
X-XSS-Protectionheader

helmet sets this to 0 — explicitly disabling the legacy browser filter rather than enabling it. Guides recommending 1; mode=block are describing a feature browsers removed because it could be turned against the page it protected.

accepts
0
default
0
set in
helmet()
Read in: helmet 8.3.0 index.cjs
X-Content-Type-Optionsheader

Set by default.

accepts
nosniff
default
nosniff
set in
helmet()
Read in: helmet 8.3.0 index.cjs
X-Powered-Byheader

helmet removes the header Express sets.

default
removed
set in
helmet()
Read in: helmet 8.3.0 index.cjs
X-Frame-Optionsheader

helmet sends SAMEORIGIN, not DENY — so by default your own origin may still frame the page. Worth knowing before you decide this control is already handled: if the requirement is that nothing frames the page at all, pass action: "deny". Note the default CSP below also carries frame-ancestors 'self', which is the modern control browsers actually prefer, so tightening only one of the two leaves the other advertising the looser policy.

accepts
SAMEORIGIN | DENY
default
SAMEORIGIN
set in
helmet()
Read in: helmet 8.3.0 index.cjs:292 — getHeaderValueFromOptions({action = "sameorigin"}) uppercased to SAMEORIGIN; also observed on the wire from app.use(helmet())
Content-Security-Policyheader

helmet ships an enforcing default 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. This and the one-year HSTS are the two defaults that break things, and between them they account for most reverts of app.use(helmet()).

accepts
default-src 'self' | script-src 'self' | object-src 'none' | script-src-attr 'none' | style-src 'self' https: 'unsafe-inline' | upgrade-insecure-requests
default
a real policy, not an empty one
set in
helmet()
Read in: helmet 8.3.0 index.cjs
if you see this
Content-Security-Policy received an invalid directive value for "default-src"

A whole policy string was pasted into one directive's array, semicolons included. Each directive takes only its own values; the semicolons belong between keys of the directives object, not inside a value.

Read in: helmet — reproduced on helmet 8.3.0
Content-Security-Policy received a duplicate directive "default-src"

The same directive was given twice under both spellings — `defaultSrc` and `default-src` normalise to one name. Helmet refuses rather than picking a winner.

Read in: helmet — reproduced on helmet 8.3.0
Content-Security-Policy needs a default-src but none was provided. If you really want to disable it, set it to `contentSecurityPolicy.dangerouslyDisableDefaultSrc`.

Raised when `useDefaults: false` is set and no default-src is supplied. Note what helmet does NOT check: a misspelled directive name is accepted and shipped verbatim, so `scriptSrc2` becomes a `script-src2` directive that every browser ignores, silently.

Read in: helmet — reproduced on helmet 8.3.0

Three defaults worth knowing before you ship them

Section titled “Three defaults worth knowing before you ship them”

X-XSS-Protection: 0 is deliberate. Helmet sets the header to disable the legacy browser XSS filter. A large body of “secure your Express app” material still says to send 1; mode=block; that filter could be manipulated into disabling a page’s legitimate scripts, and browsers removed it. Helmet is right and the guides are stale. This is the same conclusion Django reached by removing its setting in 4.0, and the same one Spring Security reaches by sending 0 itself.

HSTS is one year, immediately. max-age=31536000; includeSubDomains ships the moment you add helmet(). A browser that sees it will refuse plain HTTP for this host and every subdomain for a year, and it will not forget on request. If any subdomain does not serve HTTPS yet, that is an outage you cannot undo by removing the header. Ramp it instead:

app.use(helmet({ hsts: { maxAge: 60 } })); // then 3600, 86400, 31536000

The default CSP is real and will break most applications. It includes default-src 'self', script-src 'self', object-src 'none' and upgrade-insecure-requests. Any CDN script, analytics snippet, or inline <script> stops executing. This is the single most common reason app.use(helmet()) gets reverted rather than configured.

Note X-Frame-Options: SAMEORIGIN here, where Django defaults to DENY — helmet is the more permissive of the two.

before you ship this

Adding helmet() to an existing app changes twelve headers at once, and the CSP and HSTS are the two that bite.

Turn the CSP off while you fit the rest, then reintroduce it in report-only mode and read the violations before enforcing:

app.use(helmet({ contentSecurityPolicy: false }));

If a proxy in front of Express also sets these headers you now have two sources of truth and possibly duplicate values. Decide which layer owns them — that question is on application vs server.