HTTP Security Headers Guide: 6 to Ship, 3 to Stop Copying

2026-05-22 · Nico Brandt

Almost 40% of the top one million websites still ship without X-Content-Type-Options: nosniff — a header that takes one line to add. The reference guides aren’t helping. Every HTTP security headers guide on page one of Google lists ten-plus directives with equal weight, no opinions, and zero pre-deploy workflow. So devs copy-paste a wall of headers from StackOverflow, hope for the best, and ship.

You need six. Three of the ones you’re probably setting are actively wrong. And the audit that proves your config actually works takes 60 seconds before every push. Here’s the field guide — opinionated where the rest are encyclopedic.

The 6 Headers That Actually Matter

Out of the dozen-plus directives you’ll see in any security headers checklist for developers, six block real attacks. The rest are deprecated, nice-to-haves with significant tradeoffs, or not security headers at all.

Strict-Transport-Security — forces HTTPS for every future visit. Without it, the first request to your domain is plaintext and any active network attacker can SSL-strip the connection. OWASP recommends max-age=63072000 (two years). HSTS header configuration that ships less than a year of max-age is theater.

Content-Security-Policy — the last line of defense against XSS. When something injectable does slip through, CSP decides whether the injected script actually runs. Which makes where you store your auth tokens matter even more — CSP blocks script execution, but a stolen token in localStorage is still gone until expiry. It’s also the only header on this list that can break your site, so it gets special handling later.

X-Content-Type-Options: nosniff — stops browsers from guessing the MIME type of a response. Without it, a .txt upload containing JavaScript can execute when served from your origin. One line, zero downside, four in ten sites don’t have it.

X-Frame-Options: DENY (or CSP frame-ancestors) — blocks clickjacking. Attackers can’t iframe your login page under a fake “click here to win” overlay if browsers refuse to render your site in their iframe.

Referrer-Policy — controls what URLs leak to third parties via the Referer header. Default to strict-origin-when-cross-origin. Anything stricter and you’ll break your own analytics.

Permissions-Policy — disables browser features your site doesn’t use: camera, geolocation, USB, payment APIs. If your marketing site has no reason to ask for the camera, deny it explicitly so any compromised third-party scripts can’t either.

What about COOP, COEP, X-XSS-Protection, HPKP, X-DNS-Prefetch-Control? Skip them by default. COOP/COEP/CORP harden against Spectre-class attacks but break a lot of third-party embeds — opt in deliberately when you actually need them. X-XSS-Protection is deprecated (more on that in a minute). HPKP is dead. The rest aren’t security headers.

The list is short. The configs are where it goes wrong.

Copy-Paste Configs for Nginx, Express, and Vercel

Three deployment targets, three working web security headers configurations. Pick the one for your stack.

Nginx

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    # The 6 — `always` ensures headers survive 4xx/5xx responses
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header Content-Security-Policy-Report-Only "default-src 'self'; report-to csp-endpoint" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=()" always;
}

Two non-obvious things here. First, always. Without it, Nginx strips your headers on any non-2xx response — which is exactly when attackers probe. A 404 from your origin should ship the same headers as a 200.

Second, the gotcha that catches everyone: add_header in a location block silently overrides every add_header from the parent server block. Not just the one you’re adding. Every. Single. One. If your /api/ location adds a single Cache-Control header, your six security headers vanish for that entire route.

Fix it by extracting the headers into a snippet and including it in every location that adds its own:

# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# ... the other 5

# In each location that needs its own add_header:
location /api/ {
    include snippets/security-headers.conf;
    add_header Cache-Control "no-store" always;
}

Note the Report-Only on CSP. We’ll promote it to enforcement once we’ve learned what the site actually loads — see the rollout note in the closer.

Express (Node.js)

The one-liner: npm install helmet, then:

const helmet = require('helmet');
app.use(helmet({
  contentSecurityPolicy: false,  // Set your own; helmet's default is too tight
  hsts: { maxAge: 63072000, includeSubDomains: true }
}));

// Add your real CSP in Report-Only mode while you learn what loads
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy-Report-Only',
    "default-src 'self'; report-uri /csp-report"
  );
  next();
});

Helmet sets 11 headers with sane defaults. The two you’ll always override: contentSecurityPolicy (the default blocks inline styles, which kills most real apps), and hsts (defaults to one year — bump it to two).

If you’d rather skip the dependency, the six manually:

app.use((req, res, next) => {
  res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
  res.setHeader('Content-Security-Policy-Report-Only', "default-src 'self'");
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), microphone=()');
  next();
});

Mount it before any route handler — including your error middleware — or your 500 pages won’t get the headers. Same trap as Nginx, different syntax.

Vercel / Next.js

vercel.json at the project root:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" },
        { "key": "Content-Security-Policy-Report-Only", "value": "default-src 'self'" },
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), geolocation=(), microphone=()" }
      ]
    }
  ]
}

For Next.js, the same shape goes in next.config.js under an async headers() function. Either works — pick one, not both, or you’ll spend an afternoon wondering why your CSP isn’t updating.

One Vercel-specific gotcha: the edge network adds its own headers (x-vercel-id, cache directives), and they’ll appear in the response. They don’t conflict with the six above, but they’re worth knowing about when you audit what users actually receive — which is the next problem.

Your headers are set. Now: are some of the ones you already had ones you should be removing?

3 Headers Everyone Copies but Shouldn’t

Every config I’ve reviewed in the last two years has at least one of these. They look responsible. They’re either broken, harmful, or one-way doors.

X-XSS-Protection: 1; mode=block — looks like it enables XSS protection. It doesn’t. The filter was removed from Chrome in version 78 (2019) because the filter itself introduced new XSS vectors — attackers could use it to bypass mitigations the page already had. Firefox and Safari never shipped it. Set X-XSS-Protection: 0 only if a compliance auditor explicitly flags its absence. Otherwise, omit it entirely.

Content-Security-Policy with 'unsafe-inline' in script-src — this is the most common CSP I see in production. It also provides almost no XSS protection. The whole point of script-src is to block injected <script> tags from executing. 'unsafe-inline' allows them. If you genuinely cannot eliminate inline scripts yet, ship CSP in Report-Only mode while you migrate to nonces or hashes — don’t deploy enforcement with 'unsafe-inline' and tell yourself you have a content security policy. You don’t.

Strict-Transport-Security with preload before you’ve verified every subdomain ships HTTPS — submission to hstspreload.org is practically irreversible. Removal takes months, and during that time any subdomain that fails to serve HTTPS becomes inaccessible from any browser with your domain on the preload list. Ship max-age=31536000; includeSubDomains first. Watch it for a few weeks. Audit every subdomain. Then add preload and submit.

Honorable mention: Referrer-Policy: no-referrer. Looks tight. Also breaks your analytics’ attribution and your affiliate links. Use strict-origin-when-cross-origin (the browser default since 2021) unless you have a concrete reason to be stricter.

You’ve removed the wrong headers and set the right ones. Now: how do you actually know what reaches the user after your CDN gets its hands on the response?

The 60-Second Pre-Deploy Audit

One curl command, run against the live URL, before every push:

curl -sI https://yourdomain.com | grep -iE \
  'strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy'

Six headers in your config, six lines in the output. Any fewer and you have a bug — usually a CDN stripping headers, or a location block in Nginx swallowing the parent config.

Two follow-up checks. First, hit a 404:

curl -sI https://yourdomain.com/this-does-not-exist

If headers vanish on the 404, you forgot always (Nginx) or your error handler bypasses the middleware (Express). This is where attackers probe, so this is where the headers need to be present.

Second — and this is the one most devs skip — run it from outside your network. Origin headers and CDN-delivered headers can differ. Cloudflare, Vercel edge, and Fastly can all strip, replace, or add headers. The only truth is what the end user receives, not what your server emits.

Drop the same curl into a CI step and the build fails when a header goes missing. Five lines of shell, no SaaS, no dashboard. securityheaders.com gives you a letter grade if you want one for your team Slack — useful for the morale boost, not a substitute for the curl check.

You’re audited and clean. There’s still one thing that can break production.

The Bottom Line: Ship the 6, Roll Out CSP Slowly

Six headers do the work: HSTS, CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy. Paste the config for your stack, run the curl audit, ship.

The one exception is CSP — the only header on the list that can take your site down. Never deploy it in enforcement mode on day one. Always start with Content-Security-Policy-Report-Only, point report-to at an endpoint you actually read, and let it collect violations for a week. You’ll find inline scripts you forgot about, third-party widgets pulling from CDNs you didn’t know about, and a font file served from the wrong subdomain. Fix those, then promote the header from Report-Only to enforcement. That’s the only safe way to learn what your site really loads.

That’s the workflow. Six headers in the config, three you removed because they were doing nothing or actively harmful, one curl one-liner in your deploy checklist. The 40% of sites missing nosniff isn’t a hard problem — it’s a checklist problem.

Bookmark the curl. Add it to your deploy script. Security headers are one half of the pre-ship checklistHTTP caching headers are the other half you need to get right before pushing.