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 strippedX-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 v8x*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.
What helmet() sets out of the box (v8 defaults):
| Header | Default value | What it blocks |
|---|---|---|
Content-Security-Policy | default-src 'self' … | Inline/foreign XSS sources |
Strict-Transport-Security | max-age=15552000 (180 days) | HTTP downgrade, MITM |
X-Frame-Options | SAMEORIGIN | Clickjacking via iframes |
X-Content-Type-Options | nosniff | MIME-sniffing attacks |
Referrer-Policy | strict-origin-when-cross-origin | Referrer leakage |
Cross-Origin-Embedder-Policy | require-corp | Unauthorized resource embedding |
Cross-Origin-Opener-Policy | same-origin | Window-handle attacks |
Cross-Origin-Resource-Policy | same-origin | Cross-origin resource reads |
X-DNS-Prefetch-Control | off | Unwanted DNS prefetch |
X-Download-Options | noopen | IE8 file-execution bug |
X-Permitted-Cross-Domain-Policies | none | Flash/PDF cross-domain reads |
X-Powered-By | removed | Tech-stack fingerprinting |
X-XSS-Protection | 0 | Legacy 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 explicitscriptSrc/styleSrcentries, and whyreportOnlyexists. - HSTS is a one-way ratchet: once a browser sees
max-age, it refuses plain HTTP to that domain until expiry — andincludeSubDomainsapplies it tostaging.andinternal.subdomains too.preloadgoes further and bakes your domain into browsers permanently; only set it when HTTPS is truly permanent. frame-ancestorsin CSP vsX-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, setframe-ancestorsto 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: truefor a few days to collect violations before enforcing, or you’ll break production assets - Use nonces for inline scripts:
'unsafe-inline'inscriptSrcneuters 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'inscriptSrc: 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
localhostsuddenly demands HTTPS on every browser you tested with - Using pre-v8 option names blindly:
frameguardand friends still work as deprecated aliases, butcontentTypeOptionsisn’t a real option — it gets silently ignored andnosniffstill applies only because it’s the default - Not setting
frame-ancestorsin CSP:X-Frame-Optionsalone is the legacy mechanism; modern browsers followframe-ancestors - Overly permissive CORS:
origin: "*"combined withcredentials: trueis rejected by browsers and insecure anyway — whitelist real origins
Further Reading
- Helmet documentation: option reference for every bundled middleware, including the v8
x*names. - OWASP Secure Headers Project: recommended header set and rationale.
- MDN — Content-Security-Policy: directive-by-directive reference.
- hstspreload.org: check eligibility and submit your domain to the HSTS preload list.
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.
Related Resources
Secure APIs with HTTP Security Headers
How to configure essential security headers like HSTS, CSP, and X-Frame-Options to protect APIs and web applications from common attacks.
RecipeSecurity Headers
Harden web applications with HTTP security headers: CSP, HSTS, X-Frame-Options, and a thorough security header checklist.
RecipeSanitize User Input
How to sanitize and validate user input in Python, Java, and JavaScript to prevent injection attacks.
RecipeProtect Web Forms Against CSRF Attacks
How to prevent Cross-Site Request Forgery attacks using synchronizer tokens, SameSite cookies, and double-submit cookie patterns.
GuideContent Security Policy: CSP Headers, Nonces, Hashes
Deploy Content Security Policy without breaking your site: directives, nonces, hashes, strict-dynamic, reporting, and framework integration.
GuideAPI Security Checklist — Authentication to Encryption
A thorough security checklist for APIs: authentication, authorization, input validation, rate limiting, encryption, logging, and deployment hardening.