---
title: Security headers checklist (and how to keep them from silently disappearing)
description: Seven response headers, the values worth sending, copy-paste config for nginx, Caddy, Cloudflare and Vercel/Next.js, the headers to stop sending, and the boring ways they vanish after you set them: a new location block, a 404 page, a redirect, a CDN migration. Plus how to notice the day one goes missing.
canonical: https://watchfor.io/blog/security-headers-checklist
---

# Security headers checklist (and how to keep them from silently disappearing)

2026-10-03 · WatchFor Team · Security · security, http, web

Seven response headers, the values worth sending, copy-paste config for nginx, Caddy, Cloudflare and Vercel/Next.js, the headers to stop sending, and the boring ways they vanish after you set them: a new location block, a 404 page, a redirect, a CDN migration. Plus how to notice the day one goes missing.

Security headers are the cheapest hardening a website gets. Each one is a
line of config, most of them can't break anything, and together they shut
down clickjacking, MIME confusion, protocol downgrades and a large share of
what an injected script could do.

They also have a habit nobody warns you about: they disappear. Not because
anyone removes them, but because someone adds a `location` block, moves the
site behind a CDN, or upgrades a framework, and the line that used to apply
to every response now applies to most of them. The site looks identical.
Nothing alerts. The scanner grade you screenshotted last year is still in a
slide deck somewhere.

This is the checklist we'd hand a new team: what to send, what each value
means, config you can paste, what to stop sending, and how to check that it's
all still there next month.

## The checklist at a glance

| Header | Send this | What it stops | Can it break things? |
| --- | --- | --- | --- |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Downgrade to plain HTTP | Yes, if a subdomain has no HTTPS |
| `Content-Security-Policy` | Start as `-Report-Only`, then enforce | Injected and hijacked scripts | Yes, it's the one that needs work |
| `X-Content-Type-Options` | `nosniff` | MIME sniffing, scripts served with the wrong type | Rarely |
| `frame-ancestors` + `X-Frame-Options` | `'self'` + `SAMEORIGIN` | Clickjacking | Only if someone embeds you on purpose |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Full URLs (and tokens in them) leaking to other sites | No |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Embedded code using features you never use | Only features you switch off |
| `Cross-Origin-Opener-Policy` | `same-origin-allow-popups` | Cross-window attacks via `window.opener` | `same-origin` can break OAuth and payment pop-ups |

Every one of these is a **response** header and is read by the browser, so
an API that only ever returns JSON to other servers needs fewer of them. The
pages people open in a browser need all of them.

## 1. Strict-Transport-Security (HSTS)

HSTS tells the browser to use HTTPS for your host from now on, upgrade any
`http://` link before it leaves the machine, and refuse to let the user click
through a certificate error. The browser ignores the header if it arrives
over plain HTTP, so it only ever counts on HTTPS responses
([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security)).

**Recommended:** `Strict-Transport-Security: max-age=31536000; includeSubDomains`

Don't jump straight to a year. The [hstspreload.org](https://hstspreload.org/)
deployment guidance ramps up in stages: `max-age=300`, then a week
(`604800`), then a month (`2592000`), waiting out each stage before the next.
The reason is `includeSubDomains`: the day you send it, every subdomain that
can't do HTTPS stops working for anyone who has seen the header. That
includes the internal ones, the old status page and the printer admin panel
someone put on `printer.example.com` in 2019.

**About `preload`.** Preloading puts your domain in a list compiled into
browsers, so even the very first visit is HTTPS. The requirements are a
`max-age` of at least 31536000, `includeSubDomains`, `preload`, and the
header on your HTTPS redirect as well. The catch is in the same document:
removal "takes months for a change to reach users with a Chrome update", and
hstspreload.org now says outright that "HSTS preloading is not recommended",
because modern Chrome and Safari already try HTTPS first for navigations.
Our opinion: send `preload` only if you own every subdomain today and are
sure you will for years. For most teams, plain HSTS with `includeSubDomains`
is the whole win. More detail in [HSTS explained](/blog/hsts-explained).

## 2. Content-Security-Policy, in report-only mode first

CSP is the list of places your page may load scripts, styles, frames and
connections from. Anything else is blocked. It is the header that turns a
cross-site scripting bug or a compromised vendor script into a console error
instead of an incident, and it's the only one on this list that takes real
work. [Content Security Policy explained](/blog/content-security-policy-explained)
covers the directives; here is how to roll it out without breaking checkout.

Ship it as `Content-Security-Policy-Report-Only` first. The browser
evaluates the policy, enforces nothing, and reports every violation. The
current way to receive reports is a named endpoint in `Reporting-Endpoints`
plus `report-to` in the policy; MDN's own example still declares the older
`report-uri` as well, because `report-to` doesn't have full cross-browser
support yet
([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only)):

```http
Reporting-Endpoints: csp="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://js.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-to csp; report-uri /csp-reports
```

Leave it running for a week of real traffic. The reports list what your site
actually loads, which is always more than anyone thinks: the tag manager, the
chat widget, the A/B testing snippet a marketer added through the CMS. Add
what's legitimate, then rename the header to `Content-Security-Policy`. Keep
the reporting directives on the enforced policy too; they are your early
warning when something new tries to run.

Two details that bite: the report-only header is ignored inside a `<meta>`
tag, and so is `frame-ancestors`. Both have to be real HTTP headers.

## 3. X-Content-Type-Options: nosniff

**Recommended:** `X-Content-Type-Options: nosniff`

With `nosniff`, the browser stops guessing what a file is from its contents.
It also blocks a script or stylesheet outright when its `Content-Type`
doesn't match, which is what stops an uploaded "image" being run as
JavaScript
([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options)).
The only thing it breaks is a server sending the wrong type for its own
JavaScript, and that is worth finding anyway. See
[MIME sniffing and nosniff](/blog/mime-sniffing-explained).

## 4. frame-ancestors and X-Frame-Options

**Recommended:** `frame-ancestors 'self'` in your CSP, plus
`X-Frame-Options: SAMEORIGIN` for older browsers. Use `'none'` and `DENY` if
nothing, including your own site, should ever frame the page.

These stop clickjacking: your page loaded invisibly inside someone else's,
so a visitor's click lands on your "Delete account" button. Two notes from
MDN worth knowing. `ALLOW-FROM` is obsolete, and modern browsers that see it
ignore the whole header, so a header written to allow one partner protects
nothing at all. And `X-Frame-Options` in a `<meta>` tag has no effect. If a
partner really must embed you, list them in `frame-ancestors`, which accepts
several origins. Background in [Clickjacking explained](/blog/clickjacking-explained).

## 5. Referrer-Policy

**Recommended:** `Referrer-Policy: strict-origin-when-cross-origin`

That has been the browser default since a spec change in November 2020, so
why send it? Because "default" means "unless something overrides it", and
OWASP's
[headers cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html)
recommends setting it explicitly. Cross-origin requests then carry only your
origin, never the path and query string, which is where reset tokens and
search terms live. For an app with secrets in URLs, `no-referrer` on those
pages is stricter still. More in [Referrer-Policy explained](/blog/referrer-policy-explained).

## 6. Permissions-Policy

**Recommended:** `Permissions-Policy: camera=(), microphone=(), geolocation=(), browsing-topics=()`

An empty list `()` switches a feature off for the page and every iframe on
it, whatever its origin. List the features you don't use. A marketing site
and most SaaS dashboards use none of these, and switching them off means an
injected script or a compromised embed can't prompt your visitors for their
camera in your name. Be careful with `payment`: if your checkout uses the
browser's Payment Request API (some wallet buttons do), leave it alone.
See [Permissions-Policy explained](/blog/permissions-policy-explained).

## 7. COOP and CORP, briefly

`Cross-Origin-Opener-Policy` decides whether a page you open, or a page that
opens you, keeps a live reference to your window. `same-origin` gives the
strongest isolation and also cuts the link that OAuth sign-in pop-ups and
some payment pop-ups use to report back. Start with
`same-origin-allow-popups`, which protects your page from being opened and
poked at while leaving the pop-ups you open working.

`Cross-Origin-Resource-Policy: same-site` on your images, scripts and API
responses tells browsers not to hand them to other sites' pages in no-cors
requests. Don't put it on assets other sites are meant to load: your public
logo, an embeddable widget, a CDN bucket shared with a partner.
`Cross-Origin-Embedder-Policy: require-corp` belongs here too, but you only
need it for cross-origin isolation (`SharedArrayBuffer`, high-resolution
timers), and it blocks every cross-origin resource that hasn't opted in.

## What not to send

**The server version.** `Server: nginx/1.31.6` tells a scanner exactly which
advisories to try first. `server_tokens off;` in nginx keeps the name and
drops the version. Remember that OWASP's own note applies: attackers have
other ways to fingerprint you, so this is hygiene, not a defence.

**`X-Powered-By`.** Express sends it by default (`app.disable('x-powered-by')`
turns it off), Next.js sends it by default (`poweredByHeader: false`). Nothing reads it except people looking for targets.

**`X-XSS-Protection: 1; mode=block`.** It's deprecated and, per MDN, can
create XSS vulnerabilities in otherwise safe pages. OWASP's advice is to not
set it or to send `X-XSS-Protection: 0`. Same for `Expect-CT` and
`Public-Key-Pins`: both are retired, and pinning in particular can lock your
own users out.

That last point matters if you use Cloudflare. Its **Add security headers**
Managed Transform still adds `x-xss-protection: 1; mode=block` and
`expect-ct: max-age=86400, enforce`, next to the useful ones
([Cloudflare docs](https://developers.cloudflare.com/rules/transform/managed-transforms/reference/)).
Writing your own Transform Rule takes five minutes and sends only what you
mean.

## Copy-paste config

The values below are the checklist above. Adjust the CSP to your site after
the report-only week; everything else is safe to paste as-is for most sites.

### nginx

```nginx
server {
    listen 443 ssl;
    server_name example.com;
    server_tokens off;

    # nginx 1.29.3+: let location blocks add headers without
    # dropping these (see "How headers disappear" below).
    add_header_inherit merge;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()" always;
    add_header Cross-Origin-Opener-Policy "same-origin-allow-popups" always;
    add_header Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-uri /csp-reports" always;
}
```

Two words in there do most of the work. `always` makes nginx add the header
to every response code; without it, only 200, 201, 204, 206 and the
redirect codes get it, so your 404 and 500 pages go out bare
([nginx docs](https://nginx.org/en/docs/http/ngx_http_headers_module.html)).
`add_header_inherit merge` is new in nginx 1.29.3. On older versions, put
the headers in a snippet and `include` it in every `location` that has its
own `add_header`.

### Caddy

```caddy
example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()"
        Cross-Origin-Opener-Policy "same-origin-allow-popups"
        Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-uri /csp-reports"
        -Server
    }
    reverse_proxy app:3000
}
```

Caddy answers with `Server: Caddy` by default; `-Server` removes it. If the
app behind `reverse_proxy` sets one of these headers itself and you want
Caddy's value to win, write it with the `>` prefix (`>Referrer-Policy ...`).
That defers the operation until the upstream has written its headers, as the
[Caddy docs](https://caddyserver.com/docs/caddyfile/directives/header)
explain.

### Cloudflare (Transform Rules)

In the dashboard, go to **Rules → Overview → Create rule → Response Header
Transform Rule**. Apply it to all incoming requests and add one **Set
static** operation per header (up to 30 in one rule):

| Operation | Header name | Value |
| --- | --- | --- |
| Set static | `X-Content-Type-Options` | `nosniff` |
| Set static | `X-Frame-Options` | `SAMEORIGIN` |
| Set static | `Referrer-Policy` | `strict-origin-when-cross-origin` |
| Set static | `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), browsing-topics=()` |
| Set static | `Cross-Origin-Opener-Policy` | `same-origin-allow-popups` |
| Remove | `X-Powered-By` | |

Turn on HSTS separately under **SSL/TLS → Edge Certificates**, which has its
own settings for `max-age`, `includeSubDomains` and `preload`. Useful details
from the
[Cloudflare docs](https://developers.cloudflare.com/rules/transform/response-header-modification/):
these rules also apply to Cloudflare's own error pages, they can't change
the `server` header, and **Set** overwrites a value your origin already sent,
which is usually what you want.

### Vercel and Next.js

On Vercel without a framework, `vercel.json`:

```json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" },
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), browsing-topics=()" },
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin-allow-popups" }
      ]
    }
  ]
}
```

In Next.js, the same list goes in `next.config.ts`, and this is also where
the `X-Powered-By: Next.js` header gets switched off:

```ts

const securityHeaders = [
  { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "SAMEORIGIN" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), browsing-topics=()" },
  { key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" },
];

const nextConfig: NextConfig = {
  poweredByHeader: false,
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

```

Vercel already sends `Strict-Transport-Security: max-age=63072000` on custom
domains, without `includeSubDomains`
([Vercel docs](https://vercel.com/docs/encryption)); the entry above
replaces it with yours. For a CSP with per-request nonces, follow the
Next.js
[Content Security Policy guide](https://nextjs.org/docs/app/guides/content-security-policy)
instead of putting a static policy in `headers()`.

## How headers disappear in practice

None of these is exotic, and none of them shows up in a browser.

**A new `location` block in nginx.** This is the classic, and it surprises
experienced people. nginx inherits `add_header` from the level above "if and
only if there are no `add_header` directives defined on the current level".
Here is a real run against nginx 1.31.6 with the security headers on the
`server` block and a `location /assets/` that adds only `Cache-Control`:

```text
$ curl -sI http://127.0.0.1:18089/
HTTP/1.1 200 OK
Server: nginx/1.31.6
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

$ curl -sI http://127.0.0.1:18089/assets/app.js
HTTP/1.1 200 OK
Server: nginx/1.31.6
Cache-Control: public, max-age=31536000, immutable

$ curl -sI http://127.0.0.1:18089/missing
HTTP/1.1 404 Not Found
Server: nginx/1.31.6
```

One line of caching config removed every security header from every asset.
The 404 lost them for the second reason, the missing `always`. With
`server_tokens off`, `always` and `add_header_inherit merge`, all three
responses carry both headers and the version is gone.

**Error pages and other "special" responses.** The 404, the 500, the
maintenance page and the "502 Bad Gateway" your load balancer generates
when the app is down are often served by a different layer than your pages.
Those are exactly the responses nobody looks at.

**Redirects.** Apex to `www`, the old path to the new one, a trailing
slash added. If the headers are set by your app and the redirect is answered
by the proxy in front of it, the redirect goes out bare. For HSTS that
matters: the preload requirements explicitly say an extra redirect on your
HTTPS site must still carry the header. (The `http://` to `https://` hop
can't help either way: browsers ignore HSTS sent over plain HTTP.)

**A CDN or proxy change.** Headers were set at the origin, the site moves
behind a CDN, and someone recreates them as edge rules for the main hostname
only. Or the reverse: they lived in the old CDN's config, which nobody
copied when the account changed. A new reverse proxy can also overwrite or
strip headers the app sends.

**A framework or hosting change.** A Next.js app switched to
`output: 'export'` loses `headers()` entirely: the docs list headers among
the features a static export doesn't support, so whatever serves the files
now owns them. Moving from a server to a static host, or from one platform
to another, has the same effect.

**A different path or host.** The marketing site on a website builder, the
docs on another platform, `/api` routed to a different service, the help
centre on a vendor's domain. Your config protects the paths it covers. Your
users see all of them as your site.

## How to check them

Start with `curl`. `-I` asks for headers only, and `-L` follows redirects so
you see every hop:

```bash
curl -sIL https://example.com | grep -iE '^(HTTP|strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy|cross-origin|server|x-powered-by)'
```

Then check the responses the checklist above says get forgotten:

```bash
for path in / /login /assets/app.js /this-page-does-not-exist; do
  echo "== $path"
  curl -sI "https://example.com$path" | grep -iE '^(HTTP|strict-transport|x-content-type|x-frame)'
done
```

If you'd rather not read raw headers, our free tools make the same request
from a real probe location and explain the result: the
[Security Headers Checker](/security-headers-checker) checks the six main
headers and says what each missing one leaves open, the
[HSTS Checker](/hsts-checker) tests the policy against the preload
requirements, and the [HTTP Header Checker](/http-header-checker) shows every
header and the full redirect chain.

The problem with all of these is that they answer "is it there now?", once.
The headers in this post don't fail loudly when they go; nothing is down and
nobody complains. You find out at the next pentest, or not at all.

## Keeping them there

The fix is to turn "check the headers" from something someone remembers into
something that alerts. Three ways, from simplest to most thorough.

**A test in your deploy pipeline.** Run the `curl` loop above against
staging after every deploy and fail the build when a header is missing. It's
free and it catches config changes you made. It can't see changes made
outside your repository: a CDN rule, a platform default, a proxy someone
else runs.

**An API monitor with header assertions.** In WatchFor, an
[API monitor](/docs/monitors/api#response-assertions) makes a real request
on a schedule and checks the response, including any header. It works on
HTML pages as well as APIs. Each assertion is a row: a header name, an
operator and a value. Here are five next to the existing ones on a demo
organization's monitor, all passing:

![API monitor assertions in WatchFor, all passing: Strict-Transport-Security matches max-age with at least eight digits, X-Content-Type-Options equals nosniff, X-Frame-Options matches deny or sameorigin, Content-Security-Policy exists, and X-Powered-By does not exist, each with the actual value the endpoint returned](/blog/security-headers-checklist-assertions.png)

`exists` and `does not exist` cover presence (and `does not exist` is how
you assert `X-Powered-By` stays gone), `equals` pins an exact value, and
`matches regex` handles the rest: `max-age=\d{8,}` passes any HSTS
`max-age` of eight digits or more, roughly 115 days and up. Point one
monitor at your home page, one at a 404 path and one at a static asset, and
the three failure modes above are covered. When an assertion fails
three checks in a row (the default), the monitor goes down with that
assertion named and the actual header value next to the expected one, and
the alert goes wherever your others go. Failures
are confirmed from several locations before anyone is paged, so one odd
edge node is not an incident.

**A page integrity monitor.** Assertions check the values you thought to
write down. A [page integrity monitor](/docs/monitors/integrity) records
the security headers of each watched page as part of a baseline and tells
you when they change. Removing `Content-Security-Policy`, HSTS,
`X-Frame-Options` or `X-Content-Type-Options`, lowering the HSTS `max-age`,
or adding `'unsafe-inline'`, `'unsafe-eval'` or `*` to the CSP's
`script-src` raises a critical **Security header weakened** finding. Any
other change to a tracked header, `Referrer-Policy` and
`Permissions-Policy` included, is a warning. The same monitor watches the
page's scripts and forms, which is the subject of our post on
[catching a tampered page](/blog/magecart-injected-scripts).

![The Security headers tab of the Hacker News front page in a WatchFor page integrity monitor: the page's Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options and Referrer-Policy values, with no changes against the accepted baseline](/blog/security-headers-checklist-integrity.png)

Page integrity is on the Starter plan and above. It reads the headers of the
final page after redirects, so keep an API monitor (with redirects turned
off) for the redirect itself if HSTS on that hop matters to you.

## The short version

| Step | What to do |
| --- | --- |
| **Send** | HSTS, CSP (report-only first), `nosniff`, `frame-ancestors` + `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` |
| **Think before** | `preload`, `COOP: same-origin`, `COEP: require-corp` |
| **Stop sending** | `Server` versions, `X-Powered-By`, `X-XSS-Protection: 1`, `Expect-CT`, `Public-Key-Pins` |
| **Check** | Pages, a 404, a static asset and every redirect hop, not just `/` |
| **Keep** | Assert them on a schedule, so a missing header is an alert, not an audit finding |

Set them once this week. Then make sure you'd hear about it the day one of
them goes.

---

Canonical page: https://watchfor.io/blog/security-headers-checklist · All articles: https://watchfor.io/blog/all.md · Site guide: https://watchfor.io/llms.txt
