Security Header Audit Checklist
This is the working checklist for the Security Header Auditing & Compliance reference: a single page an engineer runs top-to-bottom against a live origin to confirm every response header is present, correctly valued, and emitted on error pages as well as on 200 OK. It exists because header policy decays. A header set that scored an A six months ago drifts as someone adds a location block that drops inherited headers, a CDN strips a directive, a framework upgrade changes a default, or an error page returns from a path that never sees the middleware. The audit below is the standing control against that drift — each row maps a header to its required value, the attack it stops, the exact command that proves it, and the deep-dive reference where the full directive grammar lives.
Threat Model & Why an Audit
A point-in-time configuration review proves nothing about production. Three failure classes account for nearly every regression an audit catches, and none of them announce themselves:
- Header drift. Configuration is edited far more often than it is re-audited. An
add_headerre-declared inside one Nginxlocationblock silently drops every inherited header for that path; an ApacheHeader setwithout thealwayscondition vanishes on the exact 5xx responses an attacker can induce. The header is still in the main config — it is just not on the response. - Missing on error pages. Security headers are part of the response, not the route. A 404, a 500, a WAF challenge page, or a static maintenance page often bypasses the application middleware entirely. An attacker who can force an error onto a victim then gets an unprotected document: no Content-Security-Policy, no framing control, no Strict-Transport-Security. The
alwaysflag and its per-platform equivalents exist precisely to close this gap. - Regressions. A framework bump flips a default, a new reverse proxy normalizes header casing or strips an “unknown” header, a marketing tag manager injects an inline script that your CSP was never updated to allow. Each is invisible until something breaks or an audit re-runs the assertions.
The audit treats every header as a control with a verifiable wire value. You do not trust the config file; you read the bytes off the response. The coverage you are actually checking is three-dimensional — present, correctly valued, and present on error responses — and a header that satisfies the first two but fails the third is still a finding.
The Checklist
Work this table top to bottom against the target origin. The Verify command column assumes you have set H=https://example.com in your shell; each command should print the expected value or nothing (a finding). The Deep dive column links to the reference with the full directive grammar, platform syntax, and rollback procedure for that header. Treat an empty result as a failed row, not a pass.
| # | Header | Required value | Why it matters | Verify command | Deep dive |
|---|---|---|---|---|---|
| 1 | Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Forces HTTPS, defeats SSL-stripping on every revisit and across subdomains. | curl -sI $H | grep -i strict-transport-security |
HSTS deep dive |
| 2 | Content-Security-Policy |
default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' (nonce or hash for scripts) |
Neutralizes XSS by restricting which scripts, styles, and frames execute. | curl -sI $H | grep -i content-security-policy |
Content Security Policy essentials |
| 3 | X-Frame-Options |
DENY (legacy mirror of frame-ancestors) |
Blocks clickjacking in engines that predate frame-ancestors. |
curl -sI $H | grep -i x-frame-options |
Frame controls & X-Frame-Options |
| 4 | CSP frame-ancestors |
frame-ancestors 'none' (or an explicit allowlist) |
The modern, spec-driven framing control; supersedes X-Frame-Options. |
curl -sI $H | grep -io "frame-ancestors[^;]*" |
X-Frame-Options vs frame-ancestors |
| 5 | X-Content-Type-Options |
nosniff |
Stops MIME sniffing that turns an uploaded file into executable script. | curl -sI $H | grep -i x-content-type-options |
Content Security Policy essentials |
| 6 | Referrer-Policy |
strict-origin-when-cross-origin (or no-referrer) |
Prevents leaking full URLs (tokens, paths) to third-party origins. | curl -sI $H | grep -i referrer-policy |
Referrer-Policy & Permissions-Policy |
| 7 | Permissions-Policy |
geolocation=(), camera=(), microphone=(), interest-cohort=() |
Disables powerful browser features the site does not use, shrinking attack surface. | curl -sI $H | grep -i permissions-policy |
Permissions-Policy: disabling features |
| 8 | Cross-Origin-Opener-Policy |
same-origin |
Severs the window.opener link, isolating the browsing context group. |
curl -sI $H | grep -i cross-origin-opener-policy |
Cross-origin isolation: COOP/COEP/CORP |
| 9 | Cross-Origin-Embedder-Policy |
require-corp (only when isolation is needed) |
Required for cross-origin isolation; gates SharedArrayBuffer and precise timers. |
curl -sI $H | grep -i cross-origin-embedder-policy |
Enabling cross-origin isolation |
| 10 | Cross-Origin-Resource-Policy |
same-origin (or same-site) |
Stops other origins embedding your resources; mitigates Spectre-class leaks. | curl -sI $H | grep -i cross-origin-resource-policy |
Cross-origin isolation: COOP/COEP/CORP |
| 11 | Cache-Control (sensitive pages) |
no-store on authenticated/sensitive responses |
Keeps secrets out of shared caches and the back/forward cache. | curl -sI $H/account | grep -i cache-control |
Cache-Control & Clear-Site-Data |
| 12 | Clear-Site-Data (logout only) |
"cache", "cookies", "storage" on the logout response |
Purges client-side state at logout so a shared device retains no session. | curl -sI $H/logout | grep -i clear-site-data |
Using Clear-Site-Data on logout |
| 13 | X-Powered-By |
absent | A removed banner denies attackers cheap framework/version fingerprinting. | curl -sI $H | grep -i x-powered-by (expect no output) |
Removing X-Powered-By and Server headers |
| 14 | Server |
minimized (e.g. nginx, no version) |
A version-stripped Server value slows targeted CVE reconnaissance. |
curl -sI $H | grep -i "^server:" |
Removing X-Powered-By and Server headers |
| 15 | Legacy X-XSS-Protection |
0 or absent |
The legacy auditor is deprecated and itself exploitable; disable, do not enable. | curl -sI $H | grep -i x-xss-protection |
Deprecated headers & legacy support |
For every row that passes on the homepage, repeat the command against a 404 (curl -sI $H/this-path-does-not-exist) and, where you can induce one, a 5xx. A header that is present on 200 but absent on 404 is the single most common drift finding — the always flag covers it on Nginx and Apache, and the full per-platform syntax is in each linked reference. The implementation patterns for the whole set are consolidated in the Web Security Headers Fundamentals reference and applied per stack in the Server & Platform Implementation Guides.
Platform-Specific Implementation
Below is a known-good baseline for the full header set, ready to paste and then tighten. Emit headers at the edge or reverse proxy so they cover error pages and responses served while the application is down. The CSP shown is a starting skeleton — replace 'nonce-...' with a per-request nonce as described in Content Security Policy essentials before treating it as production-grade.
Nginx
server {
listen 443 ssl;
server_name example.com;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Strip fingerprinting banners
server_tokens off; # removes the nginx version from Server
proxy_hide_header X-Powered-By;
more_clear_headers "X-Powered-By"; # requires headers-more-nginx-module
}
The always flag is mandatory on every add_header: without it Nginx omits the header on 4xx/5xx responses, leaving exactly the error pages an attacker can induce unprotected. The critical trap is inheritance — declaring any add_header inside a location block discards all headers inherited from server, so you must re-declare the full set in any location that adds its own. Detail in the Nginx security headers configuration reference. Verify: curl -sI https://example.com.
Apache
<VirtualHost *:443>
ServerName example.com
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=(), interest-cohort=()"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Resource-Policy "same-origin"
# Strip fingerprinting banners
Header always unset X-Powered-By
ServerTokens Prod # Server: Apache (no version)
ServerSignature Off
</VirtualHost>
always is the condition argument to set: it ensures the header is appended even on internally generated error documents, where the default onsuccess table skips them. Requires mod_headers (apachectl -M | grep headers). ServerTokens and ServerSignature belong in the main server config, not per-directory. Full patterns in the Apache .htaccess and VirtualHost hardening reference. Verify: curl -sI https://example.com.
Verification & Diagnostic Workflows
One command dumps every header for a fast visual pass — the same quick-check pattern used on the home page, widened to the full audit set:
curl -sI https://example.com | grep -iE 'strict-transport-security|content-security-policy|x-frame-options|x-content-type-options|referrer-policy|permissions-policy|cross-origin-(opener|embedder|resource)-policy|cache-control|clear-site-data|x-powered-by|^server:'
Then confirm the headers survive an error response, because that is where drift lives:
curl -sI https://example.com/this-path-404s | grep -iE 'strict-transport-security|content-security-policy|x-frame-options'
# All three should still print. Empty output is a finding.
Validate the TLS chain before trusting Strict-Transport-Security — HSTS hard-fails on any certificate error, so a SAN gap on a subdomain is a lockout waiting to happen:
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
| grep -E 'Verify return code|subject='
# Verify return code: 0 (ok)
For a graded report and a second opinion, run the origin through an external scanner. The grade letters and what they reward (and the gaps they ignore) are explained in header grading with the Observatory and securityheaders, and the full local command toolkit — curl, openssl, and testssl.sh — is documented in auditing headers with curl, openssl, and testssl. Treat the external grade as a sanity check, not the source of truth: scanners test one URL, the audit table tests your error pages too.
Troubleshooting & Safe Rollback
- Header present on
200but missing on404/500→ thealwaysflag (Nginx/Apache) or the framework’s error-handler path is dropping it. Re-emit at the edge so every response is covered. - All
server-level headers vanish under one path → anadd_header(Nginx) inside thatlocationdiscarded the inherited set. Re-declare the full block in the location, per the Nginx inheritance reference. - Duplicate header (two values) → both the CDN/edge and the origin emit it. Browsers may honor the first or the strictest; remove one source so the wire value is unambiguous.
Content-Security-Policybreaks the page after tightening → deploy it first asContent-Security-Policy-Report-Only, collect violations, then enforce. Reporting setup is in CSP violation reporting and monitoring. Rollback is non-destructive: switch back to report-only or relax the offending directive.- HSTS lockout on a subdomain → a
NET::ERR_CERT_*page with no proceed button meansincludeSubDomainsis enforcing on a host whose certificate SAN does not cover it. Fix the certificate; you cannot click through. To unwind enforcement, serveStrict-Transport-Security: max-age=0over HTTPS — but cached clients stay locked until their existingmax-agelapses, so never preload coverage you cannot guarantee. Cross-Origin-Embedder-Policy: require-corpbreaks third-party embeds → COEP demands every subresource carry CORP or CORS headers. Roll back by removing COEP first (it is the gating header), then re-introduce per debugging COEP-blocked resources.Clear-Site-Datalogging users out unexpectedly → it was emitted on a path other than/logout. Scope it to the logout response only; it is destructive to client state by design.
Frequently Asked Questions
Which headers belong in a minimum baseline versus a hardened one?
The minimum baseline that every site should pass is Strict-Transport-Security, Content-Security-Policy, a framing control (X-Frame-Options plus CSP frame-ancestors), X-Content-Type-Options: nosniff, and Referrer-Policy. A hardened profile adds Permissions-Policy, the Cross-Origin-* isolation trio, Cache-Control: no-store on sensitive responses, Clear-Site-Data at logout, and removal of X-Powered-By plus a minimized Server.
Why does the audit insist on checking error pages?
Because security headers are a property of the response, not the route, and 404/500/maintenance responses frequently bypass the application middleware that sets them. An attacker who can force an error then receives an unprotected document. The always flag on Nginx and Apache is the fix; the audit re-runs each command against a 404 to prove it.
Is X-Frame-Options redundant now that CSP has frame-ancestors?
Keep both. frame-ancestors is the modern, spec-driven control and takes precedence in browsers that support it, but X-Frame-Options: DENY still covers older engines and is cheap to send. Set them to equivalent policies so they cannot disagree.
Should I enable X-XSS-Protection?
No. The legacy XSS auditor is deprecated, removed from modern browsers, and was itself a source of information-disclosure vulnerabilities. If the header appears at all, set it to 0. Real XSS defense is a strict Content-Security-Policy.
How often should this checklist run? On every deployment as an automated gate, and on a schedule against production. Drift is introduced by routine config and dependency changes, so a once-a-quarter manual review is not enough — wire the table’s assertions into CI as covered in automated header scanning in CI/CD.