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)
whitelist what scripts/styles/images can load — blocks XSS
force HTTPS for this domain, no downgrade attacks
block iframe embedding — stops clickjacking
browser must trust Content-Type, no guessing
don't leak full URLs in Referer header
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-limitfor 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.