Most nginx configs are one add_header away from leaking more than they should. Here's a 2026 security-header checklist you can actually verify — plus the inheritance gotcha that silently disables half of them.

The headers that matter

  • Strict-Transport-Security — force HTTPS for future visits. max-age=63072000; includeSubDomains; preload.
  • X-Content-Type-Options: nosniff — stop MIME sniffing.
  • Content-Security-Policy — the big one. Even a minimal frame-ancestors 'self' (clickjacking) and a scoped default-src beats nothing. Build it per app.
  • Referrer-Policy: strict-origin-when-cross-origin — stop leaking full URLs.
  • X-Frame-Options: SAMEORIGIN — legacy clickjacking guard (CSP frame-ancestors supersedes it, but harmless to keep).
  • Drop server_tokens off to stop advertising your exact nginx version.

The gotcha that breaks everything

add_header does not inherit into a block that defines its own add_header. If you set security headers at server level and then add a single add_header inside a location (say, a cache header), every security header vanishes for that location — silently, no error.

Fix: define headers once at the broadest scope and don't re-declare add_header in child blocks, or repeat them (an include snippet of headers is the clean pattern).

Don't serve your dotfiles

A classic exposure — .git/, .env, .htpasswd reachable over HTTP:

location ~ /\.(?!well-known) { deny all; return 404; } 

Verify, don't assume

Check what you actually send:

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

If a header you configured isn't there, you probably hit the add_header inheritance trap.

I put these rules (plus TLS protocol/cipher and server_name checks) into nginx-doctor — point it at your config and it prints each finding with the fix. Zero-config Go binary, read-only.