Node.js Express Helmet Configuration
This guide is part of the Server & Platform Implementation Guides reference and covers securing Express applications with Helmet at the application layer. Helmet is Express middleware that emits HTTP response headers instructing the browser to enforce strict execution, framing, and transport boundaries. It mitigates cross-site scripting (XSS), clickjacking, MIME-type confusion, and protocol downgrade attacks — but only when middleware ordering is correct and the policy-bearing headers (notably Content-Security-Policy) are configured explicitly rather than left at their permissive defaults.
Threat Model & HTTP Header Architecture
Response headers are the first line of client-side defense: they run inside the browser’s security engine and constrain what scripts execute, where the page may be framed, and which transport is permitted. Express middleware executes in registration order, so Helmet must run before any route handler or response-generating middleware. A handler that calls res.send() flushes headers; once flushed, Helmet can no longer set them, and the response ships unprotected. Register app.use(helmet()) immediately after creating the app and before routers, static file middleware, or template rendering.
The following matrix maps common attack vectors to the Helmet option that mitigates them, the header that option emits, and the relevant OWASP Top 10 (2021) category. The first three columns are what you configure; the OWASP column is what an auditor maps it to.
| Attack Vector | OWASP Category | Helmet Option | Header Emitted | Mitigation Mechanism |
|---|---|---|---|---|
| Reflected / stored / DOM XSS | A03:2021 Injection | contentSecurityPolicy |
Content-Security-Policy |
Restricts script execution sources; eliminates 'unsafe-inline' via nonces or hashes |
| Clickjacking / UI redressing | A05:2021 Security Misconfiguration | frameguard + CSP frame-ancestors |
X-Frame-Options + Content-Security-Policy |
Denies or restricts who may embed the page in a frame |
| MIME-type confusion | A05:2021 Security Misconfiguration | noSniff |
X-Content-Type-Options: nosniff |
Stops the browser overriding the declared Content-Type |
| Protocol downgrade / SSL stripping | A05:2021 Security Misconfiguration | hsts |
Strict-Transport-Security |
Forces HTTPS for the configured max-age, defeating downgrade MITM |
| Referrer leakage | A01:2021 Broken Access Control | referrerPolicy |
Referrer-Policy |
Limits how much URL data leaks to cross-origin navigations |
| Cross-origin data theft (Spectre-class) | A05:2021 Security Misconfiguration | crossOriginResourcePolicy |
Cross-Origin-Resource-Policy |
Blocks other origins from embedding this resource |
| Speculative DNS prefetch leakage | A05:2021 Security Misconfiguration | dnsPrefetchControl |
X-DNS-Prefetch-Control |
Disables speculative DNS resolution of linked hosts |
| Server/version fingerprinting | A05:2021 Security Misconfiguration | hidePoweredBy (on by default) |
removes X-Powered-By |
Removes the Express version disclosure header |
Middleware ordering
The single most common Helmet failure is ordering. The pipeline must place Helmet upstream of everything that writes a response:
Helmet 8 defaults
Helmet v8 activates a conservative set of headers when you call helmet() with no options:
Content-Security-Policy— a restrictive default policy (default-src 'self', plusscript-src,style-src,img-src, etc.). This default rarely matches a real app and frequently breaks inline scripts and third-party assets, which is why CSP must be configured explicitly (see below).Cross-Origin-Opener-Policy: same-originCross-Origin-Resource-Policy: same-originOrigin-Agent-Cluster: ?1Referrer-Policy: no-referrerStrict-Transport-Security: max-age=15552000; includeSubDomains(180 days, nopreloadby default)X-Content-Type-Options: nosniffX-DNS-Prefetch-Control: offX-Download-Options: noopenX-Frame-Options: SAMEORIGINX-Permitted-Cross-Domain-Policies: noneX-XSS-Protection: 0— deliberately disabled; the legacy IE/Chrome XSS auditor introduced its own vulnerabilities and is superseded by CSP.X-Powered-Byis removed (no header is added; the disclosure header is stripped).
In Helmet v8 Cross-Origin-Embedder-Policy is off by default — it is not emitted unless you enable it, because require-corp breaks most third-party embeds. Enabling COEP/COOP/CORP together is covered in the cross-origin isolation reference.
What Helmet does NOT do by default
- It does not write a CSP that fits your app. The default policy is a generic
'self'policy; you must author Content-Security-Policy directives that match your real script/style/connect origins. Inline scripts and inline event handlers are blocked under the default — use nonces (see configuring CSP with Helmet and nonces). - It does not enable HSTS preload. The default
max-ageis 180 days with nopreloaddirective. Preload is opt-in and lock-in; see the HSTS deep dive. - It does not detect HTTPS behind a proxy. Helmet always sets
Strict-Transport-Security; whether the browser honors it depends on the request reaching the browser over TLS. Behind a terminating proxy you must configuretrust proxy(covered below). - It does not set cookies, CORS, rate limiting, or input validation. Helmet is headers only; it is one layer of defense in depth, not a substitute for
cors, secure cookie flags, or server-side validation.
Directive Syntax & Spec
Each Helmet option maps to exactly one header. The table below lists the option, the header it emits, and the v8 default. Pass false for any option to suppress its header; pass an options object to override.
| Helmet Option | Header Emitted | v8 Default | When to Deviate |
|---|---|---|---|
contentSecurityPolicy |
Content-Security-Policy |
default-src 'self'; … restrictive |
Always — author directives matching your origins; add per-request nonce |
strictTransportSecurity (hsts) |
Strict-Transport-Security |
max-age=15552000; includeSubDomains |
Raise maxAge to 1 year and add preload once subdomains have valid TLS |
frameguard |
X-Frame-Options |
SAMEORIGIN |
Set { action: 'deny' } for apps never framed; prefer CSP frame-ancestors for allowlists |
referrerPolicy |
Referrer-Policy |
no-referrer |
Set strict-origin-when-cross-origin if analytics needs the origin |
noSniff |
X-Content-Type-Options |
nosniff |
Never disable |
crossOriginOpenerPolicy |
Cross-Origin-Opener-Policy |
same-origin |
Loosen to same-origin-allow-popups for OAuth popups |
crossOriginResourcePolicy |
Cross-Origin-Resource-Policy |
same-origin |
Set cross-origin for a public CDN/asset host |
crossOriginEmbedderPolicy |
Cross-Origin-Embedder-Policy |
off | Enable require-corp only for SharedArrayBuffer / cross-origin isolation |
originAgentCluster |
Origin-Agent-Cluster |
?1 |
Leave on |
dnsPrefetchControl |
X-DNS-Prefetch-Control |
off |
Set { allow: true } only for prefetch-optimized sites |
xPoweredBy (hidePoweredBy) |
removes X-Powered-By |
removed | Never re-enable |
xssFilter |
X-XSS-Protection |
0 (disabled) |
Leave at 0; CSP supersedes it |
Malformed-syntax gotchas. CSP directives values must be arrays of strings, not a single space-joined string — scriptSrc: "'self' cdn.example.com" is silently treated as one source token and fails. Source keywords ('self', 'none', 'unsafe-inline', 'nonce-…') must keep their single quotes inside the string. Directive keys are camelCase (scriptSrc, not script-src).
Platform-Specific Implementation
Install Helmet with strict version pinning so a transitive upgrade cannot silently change your default policy:
npm install helmet@8 --save-exact
# or: yarn add helmet@8 --exact
# or: pnpm add helmet@8 --save-exact
Baseline helmet()
The minimum viable configuration. Register before all routes.
const express = require('express');
const helmet = require('helmet');
const app = express();
// Apply default security headers FIRST — before routers, static, and views.
app.use(helmet());
app.get('/', (req, res) => res.send('ok'));
app.listen(3000);
Security impact. Emits the full default header set: HSTS (180 days), nosniff, SAMEORIGIN framing, no-referrer, the cross-origin defaults, and a restrictive default CSP. X-Powered-By is removed. This is a hardened starting point but the default CSP will block legitimate inline scripts and third-party assets in most real apps — proceed to a custom CSP.
Verify. curl -I http://localhost:3000 | grep -i 'x-frame-options' returns X-Frame-Options: SAMEORIGIN.
Custom CSP with helmet.contentSecurityPolicy
Author directives matching your real origins. The robust pattern is a per-request nonce: generate a fresh random nonce for every request, expose it to the template, and reference it in CSP via a function source. This lets specific inline scripts run while keeping 'unsafe-inline' out of the policy. Full treatment lives in configuring CSP with Helmet and nonces.
const crypto = require('crypto');
const express = require('express');
const helmet = require('helmet');
const app = express();
// Generate a fresh nonce per request — MUST run before helmet().
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use(
helmet({
contentSecurityPolicy: {
// useDefaults keeps Helmet's safe baseline and merges your overrides.
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
// Function source is evaluated per request, reading the nonce.
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'https://api.example.com'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"], // emits CSP frame-ancestors; supersedes X-Frame-Options
upgradeInsecureRequests: [],
},
},
})
);
In your template, attach the nonce to each inline <script nonce="...">. Because frameAncestors: ["'none'"] is set, this provides modern clickjacking protection alongside the legacy X-Frame-Options header — see the frame-ancestors comparison for why both ship.
Security impact. Eliminates 'unsafe-inline' for scripts while permitting nonce-tagged inline scripts. objectSrc 'none' and baseUri 'self' close two classic CSP-bypass vectors.
Verify. curl -I http://localhost:3000 | grep -i content-security-policy shows a nonce-… token that changes on each request.
HSTS options
The default HSTS max-age is 180 days with no preload. For production HTTPS-only sites, raise it to one year and add preload only after every subdomain serves valid TLS — preload is browser-cached and effectively irreversible for the max-age window. See the HSTS deep dive.
app.use(
helmet({
strictTransportSecurity: {
maxAge: 31536000, // 1 year, in seconds
includeSubDomains: true, // covers every subdomain — confirm all have TLS first
preload: true, // only after submitting to hstspreload.org
},
})
);
Security impact. Mandates HTTPS for one year across the apex and all subdomains. Preload removes the first-visit downgrade window but locks the domain into HTTPS in shipped browser builds.
Verify. curl -sI https://example.com | grep -i strict-transport-security returns Strict-Transport-Security: max-age=31536000; includeSubDomains; preload.
Behind a reverse proxy
In production, Express almost always sits behind a TLS-terminating proxy (Nginx, an ALB, or Cloudflare). The proxy forwards plaintext HTTP to Express plus an X-Forwarded-Proto header recording the original scheme. Two problems follow.
1. Tell Express to trust the proxy. Without trust proxy, req.secure is false and req.protocol is http, so any conditional HTTPS logic (and accurate req.ip) misfires. Set it to the number of proxy hops, or a specific subnet — never blanket true on a public-facing app, which lets clients spoof X-Forwarded-*.
// One proxy hop in front of Express (e.g. Nginx on the same host).
app.set('trust proxy', 1);
2. Avoid duplicate headers. If Nginx or Cloudflare also sets security headers, the browser receives two copies. For Content-Security-Policy and X-Frame-Options, duplicate or conflicting values trigger the browser to apply the most restrictive policy — often breaking the page. Set each security header in exactly one layer. Either let Helmet own them and strip them at the edge, or let the edge own them and disable the matching Helmet option.
# Nginx: do NOT re-add headers Helmet already sets. If a header must be removed,
# clear what the upstream sent rather than appending a second copy:
proxy_hide_header X-Frame-Options; # only if the edge owns framing instead
proxy_pass http://127.0.0.1:3000;
For Cloudflare, disable any conflicting Transform Rules or managed header features when Helmet is the source of truth; see Cloudflare page rules & headers. For Nginx specifics on inheritance and add_header traps, see Nginx security headers configuration.
Security impact. Correct trust proxy keeps scheme/IP detection accurate; single-layer header ownership prevents the silent policy conflicts that duplicate Content-Security-Policy/X-Frame-Options cause.
Verify. curl -sI https://example.com | grep -ci x-frame-options must return 1, never 2.
Verification & Diagnostic Workflows
Manual inspection confirms the live response; automated tests prevent header drift across dependency upgrades.
A baseline curl -I against a Helmet-defaulted app produces:
$ curl -I http://localhost:3000
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:; ...
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=15552000; 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
Note the absence of X-Powered-By and the presence of X-XSS-Protection: 0 — both are intentional.
Lock the contract with a supertest/jest test as a required CI status check:
const request = require('supertest');
const app = require('./app');
describe('Security headers', () => {
it('sets a CSP and removes X-Powered-By', async () => {
const res = await request(app).get('/');
expect(res.headers['content-security-policy']).toMatch(/default-src 'self'/);
expect(res.headers['x-powered-by']).toBeUndefined();
});
it('emits HSTS and nosniff', async () => {
const res = await request(app).get('/');
expect(res.headers['strict-transport-security']).toContain('max-age=');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('rotates the CSP nonce per request', async () => {
const a = await request(app).get('/');
const b = await request(app).get('/');
expect(a.headers['content-security-policy']).not.toBe(b.headers['content-security-policy']);
});
});
Run npm test locally and wire npm run test:security as a required check in GitHub Actions or GitLab CI so a dependency bump cannot silently drop a directive. In the browser, DevTools > Network > select the document request > Headers tab lists the exact response headers; the Console reports CSP violations as they fire.
Troubleshooting, Misconfigurations & Safe Rollback
- Symptom: inline scripts/styles stopped running after enabling CSP → the default policy blocks
'unsafe-inline'. Fix: add a per-request nonce toscriptSrc/styleSrcand tag each inline<script nonce="…">; do not add'unsafe-inline'. Inlineonclick="…"handlers are never noncible — move them toaddEventListener. - Symptom: header appears twice (
curlshows twoX-Frame-Options) → both Helmet and the proxy/CDN set it. Fix: pick one layer. Disable the Helmet option (frameguard: false) orproxy_hide_headerat the edge so only one copy ships. - Symptom: headers missing on some routes → Helmet was registered after a router or static middleware that already sent the response. Fix: move
app.use(helmet())above every route, static, and view registration. - Symptom:
req.secureis false / HSTS not honored over HTTPS → Express does not see the original scheme behind the proxy. Fix:app.set('trust proxy', 1)(or the correct hop count) and ensure the proxy forwardsX-Forwarded-Proto. - Symptom: third-party widget/font/image blocked → its origin is not in the matching CSP directive. Fix: add the exact origin to
scriptSrc/styleSrc/imgSrc/connectSrc. Never widen todefault-src *, which disables CSP entirely. - Symptom: cross-origin assets blocked after enabling COEP →
require-corprequires every embedded resource to opt in via CORP/CORS. Fix: keep COEP off unless you need cross-origin isolation; if required, follow the cross-origin isolation reference.
Safe rollback. To investigate a CSP-induced breakage without dropping protection site-wide, switch to report-only mode: contentSecurityPolicy: { reportOnly: true, directives: { … } } emits Content-Security-Policy-Report-Only, which logs violations but enforces nothing. Tighten the directives from the reports, then flip reportOnly back to false. For a full revert of a single header, set its option to false (e.g. contentSecurityPolicy: false) and redeploy; this is non-destructive and takes effect on the next response — no browser cache to clear, unlike HSTS.
Frequently Asked Questions
Does app.use(helmet()) set a Content-Security-Policy automatically?
Yes — Helmet v8 ships a restrictive default CSP (default-src 'self' and related directives). But that default rarely matches a real app and blocks legitimate inline scripts and third-party assets, so you must author explicit directives and add a per-request nonce. Helmet does not infer your origins.
Where exactly must Helmet be registered in the middleware chain?
Immediately after const app = express() and before any router, express.static, body parser that responds, or view rendering. Middleware runs in registration order, and once a handler flushes the response Helmet can no longer set headers — so it must run first.
Why is X-XSS-Protection set to 0, and is that a downgrade?
No. The legacy XSS auditor in old IE/Chrome introduced its own injection and information-leak bugs and is removed from modern browsers. Helmet deliberately disables it (0) and relies on Content-Security-Policy for XSS mitigation instead.
Do I still need Nginx or Cloudflare security headers if Express uses Helmet?
You need each security header set in exactly one layer. If Helmet owns them, do not also set them at the edge — duplicate Content-Security-Policy or X-Frame-Options values make the browser apply the most restrictive combination and frequently break the page. Strip them at the proxy or disable the Helmet option, never both.
How do I configure HSTS preload through Helmet?
Set strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true, preload: true }, then submit the domain at hstspreload.org. Only enable preload after every subdomain serves valid TLS — preload is browser-baked and effectively irreversible for the max-age window.