🪖 Helmet.js: The Security Headers Your Express App Is Embarrassed It Doesn't Have
Let me paint you a picture. You've spent weeks building a beautiful Express API. You've got Zod validation, rate limiting, JWT auth — the whole nine yards. You deploy it, lean back, and feel like a proper backend engineer.
Then a security auditor runs your app through a header scanner and sends you a screenshot. Thirteen red X's in a row. Missing X-Content-Type-Options. Missing Strict-Transport-Security. Missing X-Frame-Options. Your app is out here on the internet wearing no pants.
Meet Helmet.js — the one-liner that makes your Express app look like it actually went to security school.
What Are Security Headers, Anyway?
HTTP response headers aren't just metadata noise. Browsers read them and change their behavior accordingly. They're basically instructions you send to the browser: "don't let other sites embed me in an iframe," "only load scripts from trusted sources," "never downgrade to HTTP."
When you skip them, you leave browsers guessing — and browsers guess in ways that attackers love. Missing X-Frame-Options? Hello, clickjacking. Missing X-Content-Type-Options? Hello, MIME sniffing attacks. It's a menu of vulnerabilities, and it's free for anyone who knows to look.
The annoying part? Setting these headers manually is tedious. There's about a dozen of them, each with its own syntax quirks, and you have to remember to add them to every single response.
That's exactly the problem Helmet.js solves.
Installing Helmet (The Hard Part)
npm install helmet
That's it. The hard part is done.
Using It in Express (The Easy Part)
import express from 'express';
import helmet from 'helmet';
const app = express();
// This single line sets 11+ security headers
app.use(helmet());
app.get('/api/status', (req, res) => {
res.json({ status: 'secured' });
});
app.listen(3000);
One line of middleware. That's it. app.use(helmet()) drops in like a bouncer at the door and sets a fistful of security headers on every single response your app sends.
What does it actually add? Let's crack open the response headers and see:
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: no-referrer
X-XSS-Protection: 0
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Origin-Agent-Cluster: ?1
That's eleven headers for the cost of one line. Absolute bargain.
The One Header That Needs Your Attention: CSP
Most Helmet defaults work out of the box, but Content Security Policy (CSP) needs a little customization. CSP is the header that tells browsers exactly which domains can load scripts, styles, images, and fonts on your pages. It's one of the most powerful XSS mitigations available — and also the one most likely to break your app if you set it wrong.
By default, Helmet sets a fairly strict CSP. If you're serving a REST API with no frontend, you probably don't care. But if you've got a frontend loading Google Fonts, Stripe, or any third-party widget, you'll need to whitelist those sources:
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://js.stripe.com"],
styleSrc: ["'self'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https://cdn.yourapp.com"],
connectSrc: ["'self'", "https://api.yourapp.com"],
},
},
})
);
The pattern is simple: list every domain that's allowed to supply each type of resource. Anything not on the list gets blocked by the browser. Think of it as an allowlist for your app's external dependencies.
Pro tip: If you're not sure what to put in your CSP, temporarily add
reportOnly: trueto CSP options. The browser will report violations without blocking anything, and you can watch the console to see exactly what needs whitelisting.
Customizing Other Headers
Helmet makes it easy to toggle individual protections. Say your app legitimately needs to be embedded in iframes (maybe you're building a widget or a dashboard embed):
app.use(
helmet({
// Disable X-Frame-Options so iframes work
frameguard: false,
// Or set it to allow specific origins
// frameguard: { action: 'allow-from', domain: 'https://partner.com' },
// Keep HSTS but customize the max-age
hsts: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true, // opt into the HSTS preload list
},
// Customize Referrer-Policy
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
})
);
Each middleware Helmet bundles can be tuned or disabled independently. You're not locked into defaults that don't fit your app.
What You're Actually Defending Against
Here's a quick cheat sheet of what each header actually does:
| Header | Defends Against |
|---|---|
X-Content-Type-Options: nosniff |
MIME type confusion attacks — browsers stop guessing file types |
X-Frame-Options: SAMEORIGIN |
Clickjacking — your page can't be loaded in someone else's iframe |
Strict-Transport-Security |
SSL stripping — forces HTTPS even if users type http:// |
Content-Security-Policy |
XSS — blocks scripts from untrusted sources |
Referrer-Policy: no-referrer |
Privacy leaks — stops your URLs leaking to third parties |
X-XSS-Protection: 0 |
Disables buggy browser XSS auditor (Helmet intentionally sets this to 0) |
The X-XSS-Protection: 0 one always confuses people. Isn't setting it to 0 removing protection? Yes — intentionally. The old browser XSS auditor had its own exploitable bugs and was removed from modern browsers anyway. Leaving it enabled in old browsers is worse than disabling it.
Don't Ship to Production Without It
Security headers take five minutes to add and defend against a whole category of attacks that require zero server-side code to exploit. Clickjacking, MIME sniffing, inline script injection — all of these happen entirely in the browser, triggered by missing headers your server should have sent.
Helmet is one of those rare tools where the return on investment is essentially infinite. You install it once, add one line, and you've closed a dozen browser-level attack vectors.
So if your Express app doesn't have Helmet yet: npm install helmet, add app.use(helmet()), done. Your app deserves to wear pants on the internet.
Useful links:
- Helmet.js docs
- Mozilla Observatory — scan your app's headers for free
- CSP Evaluator — check your Content Security Policy
Got a Helmet config question or a CSP horror story? Drop it in the comments — I'd love to hear what edge cases you've run into.