0x55aa
Back to Blog

🪖 Helmet.js: The Security Headers Your Express App Is Embarrassed It Doesn't Have

|
6 min read

🪖 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: true to 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:

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.

Thanks for reading!

Back to all posts