StackPractices
intermediate By Mathias Paulenko

Configure HTTP Security Headers with Helmet in Node.js

Set security HTTP headers in Express apps with Helmet — CSP, HSTS, X-Frame-Options, X-Content-Type-Options, and CORS for OWASP-compliant web security

Overview

Every response your Express app sends carries headers that either protect users or leave them exposed — clickjacking, XSS, and MIME sniffing all exploit missing ones. Helmet is a bundle of ~15 small middleware that sets those headers for you, and app.use(helmet()) turns on sane defaults with one line. This recipe covers configuring Helmet 8 with Content Security Policy (CSP), HSTS, CORS, and per-route overrides for OWASP-compliant apps.

When to Use

Use this resource when:

  • Running any Express.js web application or API in production
  • Deployments that need to pass OWASP security header checks (securityheaders.com audits, penetration tests)
  • Apps serving HTML to browsers, where CSP is your main XSS mitigation
  • Hardening an API that still benefits from nosniff, HSTS, and a stripped X-Powered-By

Helmet only works with Express and Connect-style frameworks. For Fastify use @fastify/helmet, for Koa use koa-helmet, and for Next.js set headers in next.config.js — the middleware won’t fit those setups.

Prerequisites

  • Node.js 18+
  • Express.js project (npm install express)
  • Helmet 8 (npm install helmet@8) — the option names below use the v8 x* spelling; the old v6 names (frameguard, noSniff) still work as deprecated aliases

Solution

The snippets are copy-paste ready; a runnable Express app with tests lives in the companion repository.

1. Install Dependencies

npm install express helmet@8 cors

2. Basic Helmet Setup

const express = require("express");
const helmet = require("helmet");
const cors = require("cors");

const app = express();

// Enable Helmet with default security headers
app.use(helmet());

// Enable CORS with specific origins
app.use(cors({
  origin: ["https://example.com", "https://app.example.com"],
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  credentials: true,
  maxAge: 86400, // Preflight cache: 24 hours
}));

app.get("/", (req, res) => {
  res.json({ message: "Secure API" });
});

app.listen(3000, () => console.log("Server running on port 3000"));

3. Custom Content Security Policy (CSP)

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: [
        "'self'",
        "https://cdn.jsdelivr.net",
        "https://www.googletagmanager.com",
      ],
      styleSrc: [
        "'self'",
        "https://fonts.googleapis.com",
        "'unsafe-inline'", // Required for many CSS frameworks
      ],
      fontSrc: [
        "'self'",
        "https://fonts.gstatic.com",
      ],
      imgSrc: [
        "'self'",
        "data:",
        "https:",
      ],
      connectSrc: [
        "'self'",
        "https://api.example.com",
        "https://www.google-analytics.com",
      ],
      frameAncestors: ["'none'"], // Prevent clickjacking
      baseUri: ["'self'"],
      formAction: ["'self'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
}));

4. HSTS (HTTP Strict Transport Security)

app.use(helmet({
  strictTransportSecurity: {
    maxAge: 31536000, // 1 year in seconds
    includeSubDomains: true,
    preload: true,
  },
}));

5. Fine-Grained Header Control

app.use(helmet({
  xFrameOptions: { action: "deny" },        // clickjacking (v6 name: frameguard)
  xContentTypeOptions: true,                // nosniff — default anyway
  xPoweredBy: true,                         // strip X-Powered-By (default)
  referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  xDnsPrefetchControl: { allow: false },    // X-DNS-Prefetch-Control (v6: dnsPrefetchControl)
  xDownloadOptions: true,                   // X-Download-Options: noopen (v6: ieNoOpen)
  xPermittedCrossDomainPolicies: { permittedPolicies: "none" },
  crossOriginEmbedderPolicy: { policy: "require-corp" },
  crossOriginOpenerPolicy: { policy: "same-origin" },
  crossOriginResourcePolicy: { policy: "same-origin" },
  originAgentCluster: true,
}));

6. Per-Route Header Configuration

// API routes — stricter headers, no CSP
const apiHelmet = helmet({
  contentSecurityPolicy: false,
  xFrameOptions: { action: "deny" },
});

app.use("/api", apiHelmet);

// Web routes — full CSP
const webHelmet = helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
    },
  },
});

app.use("/web", webHelmet);

7. Custom Headers Middleware

// Custom security headers not covered by Helmet
app.use((req, res, next) => {
  // Permissions Policy (Feature Policy successor)
  res.setHeader(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(), payment=()"
  );

  // X-Robots-Tag (prevent indexing of API endpoints)
  if (req.path.startsWith("/api")) {
    res.setHeader("X-Robots-Tag", "noindex, nofollow");
  }

  // Cache-Control for sensitive endpoints
  if (req.path.includes("/auth") || req.path.includes("/user")) {
    res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
  }

  next();
});

8. Verify Headers with Test

// Install: npm install --save-dev jest supertest
// Export your app (module.exports = app) before requiring it here.
const request = require("supertest");
const app = require("./app");

describe("Security Headers", () => {
  it("should set X-Content-Type-Options", async () => {
    const response = await request(app).get("/");
    expect(response.headers["x-content-type-options"]).toBe("nosniff");
  });

  it("should set X-Frame-Options", async () => {
    const response = await request(app).get("/");
    expect(response.headers["x-frame-options"]).toBe("DENY");
  });

  it("should set Strict-Transport-Security", async () => {
    const response = await request(app).get("/");
    expect(response.headers["strict-transport-security"]).toContain("max-age=31536000");
  });

  it("should set Content-Security-Policy", async () => {
    const response = await request(app).get("/");
    expect(response.headers["content-security-policy"]).toContain("default-src 'self'");
  });

  it("should remove X-Powered-By", async () => {
    const response = await request(app).get("/");
    expect(response.headers["x-powered-by"]).toBeUndefined();
  });
});

How It Works

helmet() isn’t one middleware — it’s a bundle of ~15 small ones, each responsible for a single header. Calling it bare wires up the defaults in the table below; passing an options object overrides each middleware independently.

Request pipeline with Helmet: incoming request passes through helmet middleware which sets security headers, then reaches your routes, and the response goes out carrying the configured headers

What helmet() sets out of the box (v8 defaults):

HeaderDefault valueWhat it blocks
Content-Security-Policydefault-src 'self' …Inline/foreign XSS sources
Strict-Transport-Securitymax-age=15552000 (180 days)HTTP downgrade, MITM
X-Frame-OptionsSAMEORIGINClickjacking via iframes
X-Content-Type-OptionsnosniffMIME-sniffing attacks
Referrer-Policystrict-origin-when-cross-originReferrer leakage
Cross-Origin-Embedder-Policyrequire-corpUnauthorized resource embedding
Cross-Origin-Opener-Policysame-originWindow-handle attacks
Cross-Origin-Resource-Policysame-originCross-origin resource reads
X-DNS-Prefetch-ControloffUnwanted DNS prefetch
X-Download-OptionsnoopenIE8 file-execution bug
X-Permitted-Cross-Domain-PoliciesnoneFlash/PDF cross-domain reads
X-Powered-ByremovedTech-stack fingerprinting
X-XSS-Protection0Legacy auditor quirks

Three headers deserve extra care:

  • CSP does the heaviest lifting: it whitelists where scripts, styles, and images may load from, which is what actually stops injected code. A strict default-src 'self' also blocks legit CDNs — that’s why production configs almost always need explicit scriptSrc/styleSrc entries, and why reportOnly exists.
  • HSTS is a one-way ratchet: once a browser sees max-age, it refuses plain HTTP to that domain until expiry — and includeSubDomains applies it to staging. and internal. subdomains too. preload goes further and bakes your domain into browsers permanently; only set it when HTTPS is truly permanent.
  • frame-ancestors in CSP vs X-Frame-Options: modern browsers prefer the CSP directive and ignore XFO when both exist. Helmet sets both because the header still protects users on older engines — keep them, set frame-ancestors to your real intent.

Variants

Nonce-Based CSP for Inline Scripts

const crypto = require("crypto");

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use(helmet({
  contentSecurityPolicy: {
    useDefaults: true,
    directives: {
      scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
    },
  },
}));

// Use nonce in templates
app.get("/", (req, res) => {
  res.send(`
    <script nonce="${res.locals.nonce}">
      console.log("This inline script is allowed");
    </script>
  `);
});

Report-Only CSP (Testing)

// Report violations without blocking — for testing CSP before enforcement
app.use(helmet({
  contentSecurityPolicy: {
    reportOnly: true,
    directives: {
      defaultSrc: ["'self'"],
      reportUri: ["/api/csp-report"],
    },
  },
}));

app.post("/api/csp-report", express.json({ type: "application/csp-report" }), (req, res) => {
  console.log("CSP Violation:", req.body);
  res.status(204).end();
});

ES Modules (ESM)

Everything above uses CommonJS because Express 4 defaults to it. In "type": "module" projects the setup is identical with import syntax — Helmet 8 ships dual builds:

import express from "express";
import helmet from "helmet";
import cors from "cors";

const app = express();
app.use(helmet());

Production vs Development

const isProduction = process.env.NODE_ENV === "production";

app.use(helmet({
  contentSecurityPolicy: isProduction ? {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'"],
    },
  } : false, // Disable CSP in dev for hot reload

  strictTransportSecurity: isProduction ? {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  } : false, // Disable HSTS in dev (HTTP)
}));

Verifying Your Headers

Before shipping, confirm what the server actually sends. Quickest check: curl -I https://yourapp.com and look for the headers you configured.

curl -sI https://yourapp.com | grep -iE "strict-transport|x-frame|x-content|content-security"

You should see something like:

strict-transport-security: max-age=31536000; includeSubDomains; preload
x-frame-options: DENY
x-content-type-options: nosniff
content-security-policy: default-src 'self';script-src 'self' https://cdn.jsdelivr.net

For a full audit, paste your URL into securityheaders.com — it grades your config and flags missing headers. In browser DevTools, the Network tab shows response headers per request, and the Console reports CSP violations with the exact directive that fired.

Best Practices

  • For a broader security baseline, see the API Security Checklist — Authentication to Encryption.
  • Start with report-only CSP: run reportOnly: true for a few days to collect violations before enforcing, or you’ll break production assets
  • Use nonces for inline scripts: 'unsafe-inline' in scriptSrc neuters XSS protection; per-request nonces keep it strict
  • Set HSTS only over HTTPS: browsers ignore HSTS on HTTP responses, and caching it accidentally in dev locks you out of localhost
  • Pin your helmet version in package.json: v8 renamed several options, so a silent major upgrade can change which headers get set

Common Mistakes

  • Using 'unsafe-inline' in scriptSrc: it allows any inline script, undoing most of what CSP protects against — use nonces or hashes instead
  • Forgetting to disable HSTS in development: browsers cache HSTS per domain, so localhost suddenly demands HTTPS on every browser you tested with
  • Using pre-v8 option names blindly: frameguard and friends still work as deprecated aliases, but contentTypeOptions isn’t a real option — it gets silently ignored and nosniff still applies only because it’s the default
  • Not setting frame-ancestors in CSP: X-Frame-Options alone is the legacy mechanism; modern browsers follow frame-ancestors
  • Overly permissive CORS: origin: "*" combined with credentials: true is rejected by browsers and insecure anyway — whitelist real origins

Further Reading

Frequently Asked Questions

Does Helmet make my app secure?

It makes your headers secure — that's one layer. Helmet won't save you from SQL injection, broken auth, or unsanitized input; pair it with input validation, authentication, and rate limiting.

Should I use Helmet for API-only backends?

Yes, but trim it. nosniff, X-Powered-By removal, and HSTS still matter for APIs, while CSP only helps when you serve HTML — that's why the per-route example disables contentSecurityPolicy for /api.

How do I debug CSP violations?

Turn on reportOnly: true and log what comes back — the violation report names the blocked directive and URI. In DevTools, CSP errors show up in the Console as "Refused to load … because it violates the following Content Security Policy directive".

Can I use Helmet with Next.js?

Next.js doesn't run Express middleware. Set the same headers in next.config.js via the headers() function — the values from this recipe carry over directly.

Which Helmet version should I install?

Helmet 8. npm install helmet currently resolves to v8, which renamed several options to x* spellings (frameguard → xFrameOptions, noSniff → xContentTypeOptions). The old names still work as deprecated aliases, but new code should use the v8 names shown here.

Can I set these headers manually without Helmet?

res.setHeader() works for a handful of headers, but you'd reimplement CSP directive serialization, defaults, and per-route control by hand. Helmet exists because that surface is easy to get subtly wrong.

Why did my site break after enabling CSP?

Almost always 'unsafe-inline' or a missing whitelist entry: inline scripts, external fonts, analytics, and CDN assets get blocked by default-src 'self' until you allow them. Diff your Network tab failures against the scriptSrc/styleSrc/fontSrc/imgSrc lists, or run report-only mode first.

Does Helmet work with Fastify or Koa?

Not directly — it's Express middleware. Fastify has @fastify/helmet (same options object), and Koa has koa-helmet (older API surface). The header concepts are identical; only the integration changes.

Do I still need X-Frame-Options if I set frame-ancestors?

Keep both. Modern browsers prefer CSP frame-ancestors and ignore XFO when it's present, but the header still protects users on older engines — and Helmet sets both anyway.

What does the HSTS preload flag actually do?

Setting preload alone changes nothing; it marks your HSTS header as eligible for submission to the browser preload list. Once accepted, browsers ship with your domain hardcoded as HTTPS-only — no first HTTP request ever happens. Removal takes months, so treat it as permanent.