helmet
helmet is one Express middleware that sets the batch of HTTP response headers browsers use to constrain what a page is allowed to do, and removes the X-Powered-By header Express adds. Its README counts thirteen headers from a bare app.use(helmet()), which gets you a Content-Security-Policy, Strict-Transport-Security for a year with subdomains, Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, same-origin opener and resource policies, and a handful of legacy headers. Each one can be turned off with false or configured by passing its own option object, and each is also available as standalone middleware if you want only some of them. It has zero dependencies, ships both ESM and CommonJS builds, and every header it sets is a browser instruction, not something enforced on your server.
Cheap, dependency-free, and correct about what the headers should be, so there is no reason not to run it on an Express app that serves HTML. The value comes entirely from doing the Content-Security-Policy work properly; if you disable CSP on day one you have installed a checklist item, not a defence.
Use it if
- You run an Express or Connect app that serves HTML to browsers and want a sane header baseline in one line instead of twelve res.setHeader calls you will get subtly wrong
- You want a Content-Security-Policy with per-request nonces, which helmet supports by allowing a function in a directive array that receives req and res
- You are working through a penetration test or compliance checklist that names these headers specifically, since the option names map one-to-one onto the header names
- Supply chain surface matters to you: there are no dependencies at all, so adding it does not widen your tree
- You need different policies on different paths, which the standalone middlewares make easy to mount per router
- You are going to hit the default CSP and turn it off. script-src 'self' blocks every CDN script, inline handler, and analytics tag; img-src 'self' data: blocks remote images; upgrade-insecure-requests rewrites http to https, and Safari applies it to localhost. The usual reaction is contentSecurityPolicy: false, which switches off the one header that was doing real work and leaves you with the minor ones
- You think it makes the app secure. It sets response headers and nothing else. There is no input sanitisation, no rate limiting, no CSRF protection, no authentication, and no validation. An app with helmet and a SQL injection is an app with a SQL injection
- Something in front of you already sets these headers. nginx, Cloudflare, an API gateway, or a platform like Vercel will happily add their own, and you end up with two Content-Security-Policy headers, which browsers intersect, producing a policy stricter than either one you wrote
- Your service only returns JSON to non-browser clients. CSP, X-Frame-Options, Referrer-Policy, and the cross-origin policies have no effect on a fetch from another server, so you are configuring most of the set for the two that matter, nosniff and HSTS
- You are not on Express or Connect. helmet targets that middleware signature, so Fastify wants @fastify/helmet and Koa wants koa-helmet
- You would be enabling HSTS carelessly. The default is a 365-day max-age with includeSubDomains, and once a browser has cached that, any subdomain still served over plain HTTP becomes unreachable for a year with no way to reach those users to undo it
Setup reality
The install is trivial: no dependencies, Node 18 or newer, and both import and require entry points. The friction is entirely about the defaults being stricter than most codebases expect. Mount helmet before your routes and before static file middleware, or the responses that most need the headers never get them. Then load a page: the default CSP will almost certainly break something, and the honest workflow is to start with reportOnly: true, read the violation reports, and add the origins you actually use rather than switching the header off. Local development needs its own handling, because upgrade-insecure-requests and HSTS together will make a browser force https on localhost and refuse to go back, which Safari does aggressively; disable both when not in production. helmet does check quoting and directive names and will throw on 'self' written without quotes, but it does not judge whether your policy is any good, and the README points you at an external CSP evaluator for that. The legacy option names from version 6 and earlier, hsts, noSniff, frameguard, and the rest, still work, but the types reject passing both the old and the new name for the same header.
Patterns
Turn on the default header setbasic-usage
import express from "express";
import helmet from "helmet";
const app = express();
app.use(helmet()); // 13 headers, X-Powered-By removed
app.use(express.static("public"));
app.use(routes);Mount it first. Middleware runs in order, so anything registered before helmet, including express.static, responds without these headers. Cross-Origin-Embedder-Policy is the one header not set by default, because turning it on breaks most pages that load third-party resources.
Switch individual headers offdisable-a-header
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
xDownloadOptions: false,
})
);Reach for this per header, not as a habit. Disabling contentSecurityPolicy in particular removes the only header here that stops an injected script from running, and the rest are mitigations for narrower or older problems.
Extend the default policy instead of replacing itcsp-directives
app.use(
helmet({
contentSecurityPolicy: {
directives: {
"script-src": ["'self'", "https://cdn.example.com"],
"img-src": ["'self'", "data:", "https://images.example.com"],
"connect-src": ["'self'", "https://api.example.com"],
"style-src": null, // drop the default entirely
},
},
})
);By default useDefaults is true, so what you pass is merged over helmet's baseline and a directive you do not mention keeps its default. Setting one to null removes it. Keywords must carry their quotes: 'self' without them throws at startup since version 8.
Allow specific inline scripts with a per-request noncecsp-nonce
import crypto from "node:crypto";
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(32).toString("hex");
next();
});
app.use(
helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
},
},
})
);
// in the template: <script nonce="<%= cspNonce %>">A directive entry can be a function taking req and res and returning a string, which is how the nonce changes per response. The nonce must be unpredictable and generated per request; reusing one across requests gives an attacker a value they can guess and defeats the point.
Roll out a policy without breaking the sitecsp-report-only
app.use(
helmet({
contentSecurityPolicy: {
reportOnly: true,
directives: {
"report-uri": ["/csp-report"],
},
},
})
);
app.post("/csp-report", express.json({ type: "*/*" }), (req, res) => {
log.warn({ report: req.body }, "csp_violation");
res.sendStatus(204);
});This sends Content-Security-Policy-Report-Only, so the browser reports what it would have blocked and blocks nothing. It is the only sane way to introduce CSP to an existing app: collect reports for a week, add the origins that turn out to be real, then flip reportOnly off.
Stop HSTS and https upgrades from breaking localhostdev-vs-prod
const isProd = process.env.NODE_ENV === "production";
app.use(
helmet({
strictTransportSecurity: isProd,
contentSecurityPolicy: {
directives: {
"upgrade-insecure-requests": isProd ? [] : null,
},
},
})
);Both settings make a browser force https, and once HSTS is cached for localhost every other project you run on that port is affected until you clear it in the browser's internal settings. An empty array enables a valueless directive; null removes it.
Configure Strict-Transport-Security deliberatelyhsts-preload
app.use(
helmet({
strictTransportSecurity: {
maxAge: 63072000, // 2 years
includeSubDomains: true,
preload: true,
},
})
);The default is already 365 days with includeSubDomains. Only add preload once every subdomain is on https, because submitting to the preload list bakes the policy into browsers themselves and removal takes months. Misspelling includeSubDomains throws rather than warning, which is the behaviour you want.
Apply a different policy to one part of the appper-route-policy
import helmet from "helmet";
app.use(helmet());
// let this one route be framed by a partner site
app.use(
"/embed",
helmet.contentSecurityPolicy({
directives: { "frame-ancestors": ["https://partner.example.com"] },
}),
(req, res, next) => {
res.removeHeader("X-Frame-Options");
next();
}
);Every header is exported as standalone middleware, and because helmet writes headers synchronously a later mount can overwrite or remove them for that path only. X-Frame-Options has to be removed by hand here: it only understands DENY and SAMEORIGIN, the ALLOW-FROM directive is not supported, so allowing a named third party is a frame-ancestors job.
Keep only the headers a JSON API benefits fromjson-api-subset
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginOpenerPolicy: false,
originAgentCluster: false,
xDnsPrefetchControl: false,
xDownloadOptions: false,
xPermittedCrossDomainPolicies: false,
})
);
// leaves nosniff, HSTS, X-Frame-Options, CORP, Referrer-Policy, no X-Powered-ByFor an API with no HTML, nosniff and HSTS carry nearly all the value. Keep Cross-Origin-Resource-Policy at same-origin unless another origin legitimately loads your responses, in which case set it to cross-origin rather than removing it.
Inspect the default directives before overriding themread-defaults
import helmet from "helmet";
console.log(helmet.contentSecurityPolicy.getDefaultDirectives());
// build your own on top, in code
const directives = {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
"script-src": ["'self'", "https://cdn.example.com"],
};
app.use(helmet({ contentSecurityPolicy: { useDefaults: false, directives } }));Since version 8 getDefaultDirectives returns a deep copy, so mutating the result no longer affects helmet's internal defaults. Building the object yourself with useDefaults: false is worth it when you want the policy printed in a config review rather than assembled by a merge you cannot see.
Enable cross-origin isolation for SharedArrayBuffercross-origin-isolation
app.use(
helmet({
crossOriginEmbedderPolicy: { policy: "require-corp" },
crossOriginOpenerPolicy: { policy: "same-origin" },
})
);
// or the gentler variant:
// crossOriginEmbedderPolicy: { policy: "credentialless" }COEP is off by default precisely because require-corp blocks every cross-origin resource that does not opt in with its own CORP or CORS header, which usually means images, fonts, and third-party iframes disappear. Only turn it on when you need SharedArrayBuffer or high resolution timers.
Confirm what is actually being sentverify-headers
curl -sI https://your-app.example.com | grep -iE 'content-security|strict-transport|x-frame|x-content-type|referrer|cross-origin|x-powered-by'Do this against the deployed URL, not localhost, because a proxy or CDN in front can add, strip, or duplicate headers. Two Content-Security-Policy headers are the common surprise: browsers enforce the intersection, so the effective policy is stricter than either one and the failure looks like helmet ignoring your configuration.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @fastify/helmet | npm | You are on Fastify and want the same header set registered as a plugin with per-route overrides |
| koa-helmet | npm | You are on Koa and need the same middleware adapted to its context object |
| lusca | npm | You want CSRF tokens alongside the security headers in one Express middleware rather than adding a separate package |