Helmet

intermediate express security headers

Helmet is app.use(helmet()) — one line, fifteen security headers. In simple language: it’s a checklist of HTTP headers that browsers respect to block common attacks. We don’t need to remember each header; helmet sets sane defaults.

Think of it like the default settings on a bank vault — we can tune individual locks, but the out-of-the-box config already blocks 90% of casual attacks.

npm install helmet
const helmet = require("helmet");
app.use(helmet());

Done. Let’s look at what just happened.

What helmet sets (the defaults)

Content-Security-Policy
whitelist what scripts/styles/images can load — blocks XSS
Strict-Transport-Security (HSTS)
force HTTPS for this domain, no downgrade attacks
X-Frame-Options
block iframe embedding — stops clickjacking
X-Content-Type-Options: nosniff
browser must trust Content-Type, no guessing
Referrer-Policy
don't leak full URLs in Referer header
X-DNS-Prefetch-Control, X-Download-Options, etc.
misc browser hardening

Content-Security-Policy (CSP) — the big one

CSP tells the browser “only load scripts from these places.” If an attacker injects <script src="evil.com/x.js">, the browser refuses to fetch it.

Helmet’s default CSP is strict — only same-origin assets, no inline scripts. That breaks most apps that use Google Analytics, Stripe, or inline event handlers. So we customize:

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://js.stripe.com", "https://www.googletagmanager.com"],
      styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.stripe.com"],
      frameSrc: ["https://js.stripe.com"],
    },
  },
}));

In simple language: each directive lists allowed sources for a resource type. 'self' means “same origin as the page.” Anything not whitelisted gets blocked and logged in the console.

For pure JSON APIs (no HTML rendered), CSP doesn’t matter much — there’s no DOM to inject into. We can disable it:

app.use(helmet({ contentSecurityPolicy: false }));

HSTS — force HTTPS

Strict-Transport-Security: max-age=15552000; includeSubDomains

After the browser sees this once, it refuses to talk to our domain over HTTP for the next ~6 months. Even if a user types http://app.example.com, the browser silently upgrades to https://.

Crucial for any login flow. Without HSTS, a coffee-shop attacker can downgrade the connection and steal cookies.

Only enable HSTS once we’re sure HTTPS works everywhere — once browsers cache it, we’re committed. Test with a short maxAge first.

X-Frame-Options — clickjacking

X-Frame-Options: SAMEORIGIN

Without this, an attacker can put our /transfer-money page in a transparent iframe on cute-puppies.com and trick users into clicking buttons. SAMEORIGIN says “only my own pages can iframe me.” DENY means no iframing at all.

CSP’s frame-ancestors directive supersedes this in modern browsers, but helmet sets both for older browser support.

X-Content-Type-Options: nosniff

X-Content-Type-Options: nosniff

Old browsers used to “sniff” content — if we sent a .txt file but it looked like JS, the browser might execute it. nosniff says “trust the Content-Type header and nothing else.” Closes a whole class of upload-based XSS.

A realistic setup

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

const app = express();

if (process.env.NODE_ENV === "production") {
  app.use(helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "https://js.stripe.com"],
        imgSrc: ["'self'", "data:", "https://cdn.example.com"],
      },
    },
    hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  }));
} else {
  // Looser CSP in dev so HMR / source maps work
  app.use(helmet({ contentSecurityPolicy: false }));
}

What helmet doesn’t do

  • Doesn’t validate input. SQL injection, prototype pollution — still our job.
  • Doesn’t rate-limit. Use express-rate-limit for that.
  • Doesn’t sanitize HTML. If we render user content, use a sanitizer like DOMPurify.
  • Doesn’t replace authentication. Just hardens the transport.

The headers are belt-and-suspenders defense. Helmet makes them painless — there’s basically no reason not to use it in any Express app that serves browsers.