mrkeyoor.com_
Thu 06 Aug 10:57 UTC
npmSecurityupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5Eight majors in and the shape is still app.use(helmet(options)). Breaking changes are small and documented one line each: version 8 raised the HSTS max-age from 180 to 365 days, made unquoted CSP keywords throw instead of warn, and dropped Node 16. Option names were renamed to match header names in 6.2.0 and the old ones still work.
Docs5/5The README shows the exact default value of every header, then how to configure and how to disable it, with the standalone middleware name for each. It also says out loud where helmet is weak, noting that it performs very little validation on your policy and pointing at an external evaluator, and it flags the localhost problems that HSTS and upgrade-insecure-requests cause in development.
Maintenance5/58.3.0 shipped in July 2026, the repository was pushed on 2026-08-01, and only 3 issues are open against 7 items once pull requests are counted. The changelog goes back years with dated entries, and headers get removed when the standard dies, as Expect-CT was in version 7.
Ecosystem5/5About 14.3M downloads a week and the assumed default in Express tutorials, security checklists, and starter templates. Ports exist for Fastify and Koa with the same option names, so the knowledge transfers between frameworks.

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
Skip it if

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-By

For 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

PackageRegistryPick it when
@fastify/helmetnpmYou are on Fastify and want the same header set registered as a plugin with per-route overrides
koa-helmetnpmYou are on Koa and need the same middleware adapted to its context object
luscanpmYou want CSRF tokens alongside the security headers in one Express middleware rather than adding a separate package