Security Header Auditing & Compliance
Configuring HTTP security headers is a one-time event; keeping them correct across every deploy, every error page, and every new subdomain is a continuous operation. This reference treats auditing as a measurement discipline: a repeatable lifecycle that establishes a known-good baseline, gates regressions in CI/CD, grades the live surface against external scanners, captures policy violations as telemetry, and maps each control back to a compliance requirement. It is the audit-side companion to the Web Security Headers Fundamentals reference, which establishes which headers to set and why, and to the server and platform implementation guides, which document the exact syntax per platform. Where those references answer “what should be configured,” this one answers “prove it still is — on every response, after every change.” The mechanics of the individual steps live in the security header audit checklist, the automated header scanning in CI/CD guide, and the curl, openssl, and testssl auditing reference.
The central insight of header auditing is that a security header is not a static asset — it is a runtime property of a response, and runtime properties drift. A configuration that scored an A on launch day degrades silently when a developer adds an unscoped add_header in a location block (which, in Nginx, drops every inherited header), when a new microservice serves error pages from a framework default that carries no headers, when a CDN strips an always-flagged directive it does not recognize, or when a Content-Security-Policy is loosened with a temporary 'unsafe-inline' that never gets removed. None of these surface in a manual spot-check of the home page. They surface only when auditing is automated, continuous, and assertive — when a failing header check breaks the build the same way a failing unit test does.
Security Scope & Operational Boundaries
This reference covers the measurement and verification of HTTP security headers across their full lifecycle: capturing a baseline inventory of the headers a host emits, asserting that inventory in a CI/CD pipeline so regressions fail the build, grading the deployed surface against external scanners such as the Mozilla HTTP Observatory and securityheaders.com, collecting CSP violation reports as continuous telemetry, and mapping the resulting controls onto recognized compliance frameworks (OWASP ASVS, NIST SP 800-53, PCI DSS). It establishes the commands, the pass/fail thresholds, and the evidence artifacts an auditor will ask for.
It explicitly excludes penetration testing of application logic, fuzzing, authentication bypass, business-logic abuse, and SSRF discovery — header auditing measures the browser-facing response surface, not the code paths behind it. It also excludes the depth of TLS certificate lifecycle management: this reference uses openssl and testssl.sh to confirm that the transport underpinning Strict-Transport-Security is sound (protocol version, chain validity, cipher strength), but certificate issuance, rotation, and CA selection belong to a dedicated PKI program. A clean header grade does not mean an application is secure; it means one specific, browser-enforced defense layer is present and correctly scoped. Treat the artifacts produced here as evidence for an audit, not as a substitute for one.
The controls measured here map directly onto the headers documented elsewhere on this site, and the audit is only meaningful when it checks all of them: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options and frame-ancestors, Referrer-Policy and Permissions-Policy, the cross-origin isolation headers COOP, COEP, and CORP, and Cache-Control with Clear-Site-Data. An audit that grades only the document response and ignores API, error, and redirect responses is measuring a fraction of the real surface.
Core Threat Model
The threats this reference addresses are not the attacks the headers themselves block — those are catalogued in the Fundamentals threat model. The threats here are audit gaps: the ways a correctly configured control silently stops being correct, and the detection mechanism that catches each one before an attacker does. Every row maps a drift category to the specific measurement that surfaces it. The recurring failure is the same in all cases — the control is verified once at launch and never again, so the window between regression and discovery is unbounded.
| Threat Category | Primary Attack Vector | Detection Mechanism |
|---|---|---|
| Unmonitored CSP drift | A temporary 'unsafe-inline' or wildcard script-src added during an incident is never reverted, silently re-enabling XSS payloads the policy was meant to block |
Diff the live Content-Security-Policy against a checked-in baseline in CI; alert on any new 'unsafe-inline', 'unsafe-eval', or * source |
| Missing headers on error and API responses | Framework default 404/500 pages and JSON API routes bypass the proxy block that sets headers, so injected content on an error page runs without X-Frame-Options or CSP |
Scan non-200 responses explicitly — curl -sI https://host/this-404s — and assert headers are present (validates the always flag) |
| Header regression on deploy | An unscoped add_header in a new Nginx location block drops every inherited security header for that route; a refactor removes a Header always set line |
Automated CI/CD gate that re-runs the full header assertion suite against a preview deployment and fails the build on any delta |
| Grade downgrade | A loosened Referrer-Policy or removed HSTS includeSubDomains drops the external score from A to C without breaking anything functionally |
Scheduled Observatory / securityheaders.com scan with a minimum-grade threshold that pages on regression |
| Stale or duplicated headers from intermediaries | A CDN caches an old header value or appends a duplicate, so the browser applies the most restrictive — or rejects the response — and the origin audit never sees it | Audit through the public edge, not the origin; compare edge vs origin responses with curl and testssl |
| Silent enforcement failure | A Content-Security-Policy-Report-Only policy is mistaken for an enforcing one, or a report-to endpoint stops receiving data, so violations accumulate undetected |
Continuous CSP violation reporting and monitoring with an alert when report volume drops to zero or spikes |
| Cross-origin isolation regression | A newly embedded third-party resource without CORP breaks crossOriginIsolated, silently disabling SharedArrayBuffer features and the COOP/COEP protection tier |
Assert self.crossOriginIsolated === true in a headless-browser smoke test in CI, not just the presence of the headers |
The two structural lessons across every row: audits must run against the public edge (what the browser actually receives) and across every response class (document, API, error, redirect), and they must be assertive — a check that logs a warning no one reads is not a control. The phased lifecycle below builds exactly that.
Phased Deployment Strategy
The audit lifecycle is deployed the same way the headers themselves are — in independently verifiable stages, each promoted only after the previous one is proven. Stand up the baseline first; you cannot detect drift without a known-good reference. Then make detection automatic, then external, then continuous, then mapped to compliance. Each phase has an objective, concrete commands or configuration, and verification steps that confirm the phase is live before you build the next on top of it.
Phase 1: Baseline Inventory
Objective: Capture the exact set of headers each response class emits today, on the public edge, as a checked-in artifact that every later phase diffs against.
A baseline is a literal snapshot of header output for a representative set of URLs — at minimum the home page, an authenticated route, a static asset, an API endpoint, a known 404, and a redirect. Capture it with curl against the production edge and store it in the repository so changes show up in code review. Confirm the transport underneath Strict-Transport-Security with openssl so a downgrade or weak-cipher regression is part of the same baseline. The full procedure, including how to normalize volatile headers (Date, ETag) out of the diff, is in auditing headers with curl, openssl, and testssl, and the per-header pass criteria are in the security header audit checklist.
# Capture the security-relevant headers for each response class into a baseline file.
for path in / /dashboard /static/app.js /api/health /this-404s; do
echo "### https://example.com$path"
curl -sI "https://example.com$path" \
| grep -iE 'strict-transport|content-security-policy|x-frame-options|x-content-type|referrer-policy|permissions-policy|cross-origin-|cache-control'
done > headers.baseline.txt
# Confirm the transport that HSTS depends on: TLS 1.2+ only, valid chain.
openssl s_client -connect example.com:443 -tls1_2 </dev/null 2>/dev/null \
| grep -E 'Protocol|Verify return code'
# A deeper transport baseline with testssl.sh (run from a checkout or container).
testssl.sh --quiet --protocols --headers https://example.com
# Flags: any SSLv3/TLS1.0/1.1 offered, missing HSTS, or weak cipher = baseline failure.
Verification Steps:
headers.baseline.txtcontains a non-empty block for every path including/this-404s; an empty error-page block means thealwaysflag is missing and is itself a finding.openssl s_clientreportsVerify return code: 0 (ok)and aProtocolofTLSv1.2orTLSv1.3.- The baseline is committed to version control; a subsequent
git diffon it is the canonical “did the header surface change” signal. - Re-running the capture twice produces an identical file after volatile headers are filtered, proving the baseline is deterministic.
Phase 2: Automated CI/CD Gate
Objective: Make any deviation from the baseline fail the build, so a header regression is caught in review rather than in production.
Promote the baseline from a document into an executable assertion that runs against a preview or staging deployment on every pull request. The gate fetches the same URL set, checks each required header is present with an acceptable value, and exits non-zero on any failure — identical mechanics to a failing test. Critically, it must check error and API responses, not just the document, because that is where regressions hide. The pipeline patterns, including how to spin up an ephemeral environment and run the checks against it, are detailed in automated header scanning in CI/CD.
#!/usr/bin/env bash
# ci-header-gate.sh — exit non-zero if any required header is missing or weak.
set -euo pipefail
BASE="${1:?usage: ci-header-gate.sh https://preview.example.com}"
fail=0
require() { # require <path> <header-regex> <expected-substring>
local got; got=$(curl -sI "$BASE$1" | grep -i "$2" || true)
if ! grep -qi "$3" <<<"$got"; then
echo "FAIL $1 :: expected '$3' in '$2' (got: ${got:-<none>})"; fail=1
fi
}
require "/" "strict-transport-security" "max-age=63072000"
require "/" "content-security-policy" "default-src"
require "/" "x-content-type-options" "nosniff"
require "/this-404s" "x-frame-options" "SAMEORIGIN" # validates `always`
require "/api/health" "cache-control" "no-store"
exit "$fail"
# .github/workflows/header-audit.yml — run the gate against the deploy preview.
name: header-audit
on: [pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Assert security headers
run: ./ci-header-gate.sh "$" # non-zero exit fails the PR
Verification Steps:
- Open a test PR that deliberately removes the
alwaysflag from one header; confirm theheader-auditjob turns red and blocks merge. - Confirm the gate fails when run against the
/this-404spath with a header missing, proving error-response coverage. - A passing run on an unchanged deployment produces a zero exit and a green check; the gate is required (branch protection), not advisory.
Phase 3: External Grading
Objective: Score the live, public surface against independent scanners and hold it to a minimum grade so a silent downgrade is detected.
The CI gate proves your intended headers are present; external graders score them against an evolving best-practice rubric and catch the things your assertions did not think to check. Run the Mozilla HTTP Observatory and securityheaders.com on a schedule against production, record the grade, and alert when it drops below a threshold. Treat the grade as a regression signal and a communication tool — not a target to maximize for its own sake. How each scanner weights the directives, and how to read a grade past the letter, is covered in header grading with Observatory and securityheaders.com and the focused interpreting a securityheaders.com grade walkthrough.
# Mozilla HTTP Observatory via its public API: kick off a scan, then read the grade.
host=example.com
curl -s -X POST "https://observatory-api.mdn.mozilla.net/api/v2/scan?host=$host" >/dev/null
curl -s "https://observatory-api.mdn.mozilla.net/api/v2/scan?host=$host" \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["grade"], d["score"])'
# Gate example: treat anything below a B+ (score < 75) as a regression.
# securityheaders.com returns its grade in a response header when asked for the followed scan.
curl -sI "https://securityheaders.com/?q=https://example.com&followRedirects=on&hide=on" \
| grep -i '^x-grade:'
# x-grade: A+ -> record; anything below the agreed floor pages on-call.
Verification Steps:
- A scheduled job records the Observatory grade and score daily; the time series is retained as audit evidence.
- Manually loosen
Referrer-Policyin staging and confirm the score drops, validating the threshold catches real downgrades. - The minimum-grade check is wired to the same alerting path as uptime, so a downgrade pages rather than waiting for a quarterly review.
Phase 4: CSP Violation Telemetry
Objective: Turn the browser population into a continuous sensor by collecting Content-Security-Policy violation reports and alerting on their patterns.
Static scans see one response; violation telemetry sees what real browsers actually block across every page and every third-party script, including regressions a scanner cannot reach. Keep a Content-Security-Policy-Report-Only policy running alongside the enforcing one, route both through a report-to endpoint, and parse the incoming reports into signal. A sudden spike means a new violating resource shipped; a drop to zero means the endpoint or the directive broke. The endpoint setup, report schema, and alerting thresholds are in CSP violation reporting and monitoring, and the parsing details are in parsing CSP violation reports.
# Declare a reporting endpoint and run a report-only policy beside the enforcing one.
add_header Reporting-Endpoints 'csp-endpoint="https://example.com/csp-report"' always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-to csp-endpoint" always;
# `always` keeps the report-only policy on error pages, where injected content is most likely to slip in.
Header always set Reporting-Endpoints "csp-endpoint=\"https://example.com/csp-report\""
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-to csp-endpoint"
Verification Steps:
- Trigger an intentional violation (load a script from an unlisted host on a staging page) and confirm a JSON report with the expected
blocked-uriarrives at the endpoint. - Confirm report volume is monitored both ways: an alert fires on a spike (new violating resource) and on a sustained drop to zero (broken endpoint or removed directive).
- Confirm the report-only policy persists after the enforcing policy is deployed, so future regressions surface as reports rather than outages.
Phase 5: Compliance Mapping
Objective: Tie each measured control to a named requirement in OWASP ASVS, NIST SP 800-53, or PCI DSS so the audit produces auditor-ready evidence.
The final phase translates technical findings into the language an assessor uses. Each header maps to one or more control objectives, and the artifacts from the earlier phases — the baseline file, the CI gate result, the grade time series, the violation reports — become the evidence that satisfies them. Maintain the mapping as a checked-in table so it versions alongside the configuration; the per-control detail and the evidence each one expects are enumerated in the security header audit checklist.
{
"controls": [
{ "header": "Strict-Transport-Security", "owasp_asvs": "9.1.1", "nist_800_53": "SC-8", "pci_dss": "4.2.1", "evidence": "headers.baseline.txt + openssl chain + Observatory grade" },
{ "header": "Content-Security-Policy", "owasp_asvs": "14.4.3", "nist_800_53": "SI-10", "pci_dss": "6.4.1", "evidence": "CI gate assertion + report-to violation log" },
{ "header": "X-Frame-Options", "owasp_asvs": "14.4.7", "nist_800_53": "SC-18", "pci_dss": "6.4.1", "evidence": "CI gate assertion on document + error responses" },
{ "header": "X-Content-Type-Options", "owasp_asvs": "14.4.4", "nist_800_53": "SI-10", "pci_dss": "6.4.1", "evidence": "CI gate assertion" }
]
}
Verification Steps:
- Every required control in the framework you are assessed against has a row mapping it to a header and a named evidence artifact; gaps are tracked as findings.
- The evidence artifacts are reproducible on demand — an assessor can re-run the baseline capture and the CI gate and get the same result.
- The mapping table is versioned with the codebase, so a control that regresses (Phase 2 turns red) is traceable to the compliance requirement it breaks.
Security Trade-offs & Operational Considerations
Auditing introduces its own tensions, and resolving them badly produces a process that feels rigorous while measuring nothing.
- Report-only noise vs signal. A
Content-Security-Policy-Report-Onlypolicy in the wild generates large volumes of reports from browser extensions, antivirus injection, and crawlers — noise that has nothing to do with your code. Teams that pipe raw reports to an inbox drown and stop reading. Resolve it by aggregating onblocked-uriandeffective-directive, filtering known-benign extension schemes, and alerting on new signatures and rate changes rather than individual reports. A report-only policy nobody triages is monitoring cost with no monitoring value. - Scanner false confidence. An A+ on securityheaders.com grades only the headers present on the response it fetched — typically the document, over the public edge, unauthenticated. It says nothing about your API responses, your error pages, your authenticated routes, or whether the CSP actually has a nonce that rotates. A high grade on a single URL is the most common source of false confidence in this entire domain. Treat external grades as one input alongside the CI gate that checks every response class, never as the audit itself.
- Grade-chasing vs real risk. Optimizing for the letter grade incentivizes adding headers the rubric rewards even when they do not reduce your actual risk, and tolerating a lower grade where a deliberate trade-off is correct (for example, omitting COEP on a content-heavy site that does not need
SharedArrayBuffer). The grade is a proxy, not the objective. Document deliberate deviations so a “downgrade” that is actually a correct engineering decision is not flagged as a regression — and so a real regression is not lost among accepted ones. - CI gate brittleness vs coverage. A gate that pins exact header strings breaks on every benign change (a new
report-togroup, a reordered directive) and trains the team to disable it. One that only checks presence misses amax-agequietly dropped to60. Resolve it by asserting the security-relevant substring —includeSubDomains,nosniff, the absence of'unsafe-inline'— rather than the whole string, so the gate fails on weakening and passes on cosmetic change. - Edge vs origin measurement. Auditing the origin proves what your application emits; auditing the public edge proves what the browser receives. A CDN can add, strip, cache, or duplicate headers between the two. Always grade through the edge for the authoritative result, and separately diff edge against origin to catch intermediary mutation — a clean origin audit with a contaminated edge is a finding the single-target scan will never show.
Resolve these by treating the audit as software: version the baseline and the mapping, assert substrings not strings, measure the edge and every response class, aggregate telemetry before alerting on it, and document deliberate deviations so the signal stays clean. An audit pipeline is itself a control, and like every control on this site it is only as good as its continuous, assertive verification.
Related
- Security header audit checklist — the per-header pass criteria and compliance evidence each control expects.
- Automated header scanning in CI/CD — turning the baseline into a build-breaking gate across every response class.
- CSP violation reporting and monitoring — collecting and triaging
report-totelemetry as a continuous sensor. - Header grading with Observatory and securityheaders.com — scoring the live surface and holding it to a minimum grade.
- Auditing headers with curl, openssl, and testssl — the command-line baseline capture and transport verification.