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:

Express middleware pipeline with Helmet before route handlers A request enters Express, passes through the nonce generator and Helmet (which sets security headers), then reaches routers and static middleware before the response returns to the browser. Browser request Nonce res.locals.nonce helmet() sets headers Routers / static / views Headers helmet() emits: Content-Security-Policy Strict-Transport-Security X-Content-Type-Options / X-Frame-Options Referrer-Policy + Cross-Origin-* set

Response with headers returns left-to-right back to the browser.

Helmet must be registered before any route, static, or view middleware — once a handler flushes the response, headers can no longer be set.

Helmet 8 defaults

Helmet v8 activates a conservative set of headers when you call helmet() with no options:

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

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

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.