Security Headers and Compliance Frameworks
This guide is part of the Security Header Auditing & Compliance reference, and it exists to close the gap between what an engineer configures on the wire and what an auditor writes down as satisfied. Compliance frameworks — PCI DSS 4.0, SOC 2, and OWASP ASVS v4 — do not name individual HTTP response headers in most of their control text. They describe outcomes: transport is encrypted, cardholder data is not cached at intermediaries, the application resists common web attacks. HTTP security headers are the concrete, testable implementation that produces those outcomes, and the auditor’s job is to trace each header back to the control it satisfies. This page is that trace. It maps Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, frame-ancestors / X-Frame-Options, Referrer-Policy and Permissions-Policy, and Cache-Control to specific requirements in each framework, states the evidence assessors accept, and gives a per-framework checklist you can attach to an audit response.
Threat Model & Protocol Mechanics
Compliance is not a threat actor — but the frameworks exist because real ones are. Each control that a header satisfies is written to neutralise a documented attack class. PCI DSS 4.0 Requirement 4 exists because payment card data traversing plaintext or downgradeable transport is trivially intercepted; HSTS neutralises the SSL-stripping man-in-the-middle that a 301 http -> https redirect alone leaves open, because the browser caches the max-age directive and refuses plaintext for the whole domain before a request is ever sent. Requirement 6.4.3 and 11.6.1 target script tampering and skimming (Magecart-class attacks against payment pages); CSP with a strict script-src and a report-uri/report-to endpoint is the browser-enforced control that both blocks unauthorised script execution and generates the tamper-detection signal the requirement demands.
The enforcement model matters for evidence. Two headers are cached client-side and therefore have a persistence property an auditor should understand: HSTS is remembered for its max-age and applies before the next connection; CSP is re-evaluated per response and is never cached, so a stale CSP is impossible but a missing one on a single response is an immediate exposure. Everything else — X-Content-Type-Options: nosniff, frame controls, Referrer-Policy, Permissions-Policy, Cache-Control — is enforced by the browser at parse or fetch time on that exact response, which is why an assessor’s evidence must show the header on the response bytes, not in a config file. A control that lives only in nginx.conf but is dropped on a location block or an error page is, for audit purposes, not implemented.
Directive Syntax & Spec
An assessor tests wire values, so the baseline you present must emit a specific, defensible string for every header. The block below is the compliance baseline — the exact response headers that satisfy the mapped controls when present on every response, including error pages. It is what your verification commands must return byte-for-byte.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; report-uri /csp-report
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()
Cache-Control: no-store
Each directive carries a compliance rationale, not just a security one. The table below is what you attach to an audit response so the assessor can see the requirement, the required value, and the reason it is defensible.
| Header / directive | Required value | Framework anchor | Why this exact value |
|---|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains (preload optional) |
PCI 4.2.1, SOC 2 CC6.7, ASVS 9.1.1 | One-year max-age is the accepted minimum; includeSubDomains prevents a plaintext subdomain from being a downgrade pivot. |
Content-Security-Policy |
strict script-src, object-src 'none', frame-ancestors, a report endpoint |
PCI 6.4.3 / 11.6.1, ASVS 14.4.3 | 6.4.3 requires tamper detection on payment-page scripts; the report endpoint is the detection mechanism. |
X-Content-Type-Options |
nosniff |
PCI 6.2.4, ASVS 14.4.4 | Blocks MIME-confusion XSS where a text upload is executed as script. Single accepted value. |
X-Frame-Options / frame-ancestors |
DENY / frame-ancestors 'none' |
PCI 6.2.4, ASVS 14.4.7 | Neutralises clickjacking against authenticated actions. frame-ancestors supersedes the legacy header where both are set. |
Referrer-Policy |
strict-origin-when-cross-origin or stricter |
ASVS 8.3.1, SOC 2 CC6.7 | Prevents leaking full URLs (session tokens, PAN fragments in query strings) to third parties. |
Permissions-Policy |
explicit () denials for unused features |
ASVS 14.4.x, SOC 2 CC6.1 | Reduces the browser feature attack surface; documented denial is stronger evidence than an absent header. |
Cache-Control |
no-store on authenticated and cardholder-data pages |
PCI 3.4.1 / 4.2.1, ASVS 8.2.1 | Prevents sensitive responses being retained on shared proxies, browser disk cache, or CDN edges. |
Common malformed-syntax findings that fail an audit: max-age written with quotes or a comma, includeSubDomains misspelled (it is case-insensitive but must be exact), a CSP with unsafe-inline in script-src (voids the 6.4.3 tamper-detection intent), and Cache-Control: private mistaken for adequate on a cardholder-data page — private still permits browser disk caching and does not satisfy 3.4.1.
Platform-Specific Implementation
Every block below uses the platform’s unconditional emission mechanism, because a header that is dropped on a 4xx/5xx response is a finding regardless of what the main config says. The always flag and its equivalents are the single most common audit gap.
Nginx
add_header in Nginx applies to a limited set of 2xx/3xx codes unless the directive is present in scope and inheritance rules are respected. The always parameter forces emission on every status code, including error pages an attacker can induce.
# always => header is emitted on 4xx/5xx error pages too, closing the audit gap
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; report-uri /csp-report" 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 "geolocation=(), camera=(), microphone=(), payment=()" always;
add_header Cache-Control "no-store" always;
Verify: curl -sI https://example.com/does-not-exist | grep -i strict-transport returns the HSTS header on the 404.
Full directive reference: Nginx security headers configuration.
Apache
Header set without a condition is skipped on internally generated error responses. Header always set uses the always table so the header survives 4xx/5xx.
# always => applies to error responses; plain "Header set" is dropped on 5xx
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; report-uri /csp-report"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=()"
Header always set Cache-Control "no-store"
Verify: curl -sI https://example.com/ | grep -i x-frame-options returns X-Frame-Options: DENY.
Full directive reference: Apache .htaccess and VirtualHost hardening.
Cloudflare
A Transform Rule at the edge sets headers on every response Cloudflare returns, including cached and challenge responses. The set operation in an HTTP Response Header Modification rule is the unconditional equivalent — it applies before the response reaches the client regardless of origin status.
// Transform Rule expression: true (all responses)
// Action: Set static header (applies to every edge response, incl. cached + error)
{
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"Content-Security-Policy": "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; report-uri /csp-report",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(), camera=(), microphone=(), payment=()",
"Cache-Control": "no-store"
}
Verify: curl -sI https://example.com/ | grep -i cf-cache-status confirms the edge is serving, then check the header is present on that same response.
Full directive reference: Cloudflare page rules and headers.
Node / Express (Helmet)
Helmet sets its headers in middleware that runs for every route it wraps, including the error-handling chain when mounted before the error handler. Mount it first so no route escapes it.
const helmet = require("helmet");
// helmet() runs on every request that passes through the app — mount before routes/error handler
app.use(helmet({
strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true, preload: true },
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
reportUri: ["/csp-report"],
},
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
xFrameOptions: { action: "deny" },
}));
Verify: curl -sI http://localhost:3000/ | grep -i content-security-policy returns the assembled CSP string.
Full directive reference: Node.js Express Helmet configuration.
Verification & Diagnostic Workflows
An assessor accepts three evidence forms: a raw curl -I capture with a visible timestamp and hostname, a screenshot of the browser DevTools Network panel showing the response headers, or the output of a scanning tool run against the production origin. The curl capture is the strongest because it is reproducible and shows the exact bytes. The workflow below produces an evidence file per host.
# Evidence capture: headers on a normal 200 AND on a forced error page
HOST=https://example.com
{
echo "== $(date -u) $HOST =="
curl -sI "$HOST/" | grep -iE 'strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy|cache-control'
echo "-- error page (must match) --"
curl -sI "$HOST/__audit_404__" | grep -iE 'strict-transport|content-security|x-content-type|x-frame'
} | tee evidence_$(echo "$HOST" | sed 's#https\?://##;s#/#_#g').txt
Expected output (the error-page block must contain the same security headers as the 200):
== 2026-07-24 ... https://example.com ==
strict-transport-security: max-age=31536000; includeSubDomains; preload
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; report-uri /csp-report
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
permissions-policy: geolocation=(), camera=(), microphone=(), payment=()
cache-control: no-store
-- error page (must match) --
strict-transport-security: max-age=31536000; includeSubDomains; preload
content-security-policy: default-src 'self'; ...
x-content-type-options: nosniff
x-frame-options: DENY
For the transport layer that PCI 4.2.1 tests, confirm the certificate and that HSTS is served over a valid TLS session (a header served over expired/invalid TLS is not counted):
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates -subject
# notBefore/notAfter must bracket today's date; subject/SAN must match the host
Wire this into CI so evidence is regenerated on every deploy and a regression fails the pipeline before it reaches production. The mapping of the assertion to the framework requirement is what turns a green build into audit evidence.
#!/usr/bin/env bash
# Compliance gate: fail the build if a mapped header is missing on prod
set -euo pipefail
HOST=${1:-https://example.com}
declare -A REQUIRED=(
[strict-transport-security]="PCI 4.2.1 / SOC2 CC6.7"
[content-security-policy]="PCI 6.4.3 / ASVS 14.4.3"
[x-content-type-options]="PCI 6.2.4"
[x-frame-options]="ASVS 14.4.7"
[referrer-policy]="ASVS 8.3.1"
)
H=$(curl -sI "$HOST/")
fail=0
for k in "${!REQUIRED[@]}"; do
if ! grep -qi "^$k:" <<<"$H"; then
echo "MISSING $k -> control ${REQUIRED[$k]}"
fail=1
fi
done
exit $fail
Troubleshooting, Misconfigurations & Safe Rollback
The findings below are the ones assessors write up most often, each with the exact symptom and the fix that clears it. A compliance failure is almost never “the header is missing entirely” — it is a subtle value or coverage gap.
- Symptom: header present on
/but absent on the 404/500 page. The single most common finding. Fix: add thealwaysflag (Nginx), switchHeader settoHeader always set(Apache), or move Helmet ahead of the error handler. Re-run the error-page capture until both blocks match. - Symptom: CSP contains
'unsafe-inline'inscript-src. Voids the PCI 6.4.3 tamper-detection intent because any injected inline script executes. Fix: move to nonces or hashes; remove'unsafe-inline'. If a third-party tag needs it, isolate that page and document the compensating control. - Symptom:
Cache-Control: privateon a cardholder-data or authenticated page.privatestill allows the browser to write the response to disk, failing PCI 3.4.1. Fix: useno-store, and pair with Clear-Site-Data on logout. - Symptom: HSTS served but the site still answers plaintext on a subdomain.
includeSubDomainsis claimed but a subdomain has no TLS, so the downgrade pivot remains. Fix: put valid TLS on every subdomain before assertingincludeSubDomains; an assessor tests subdomains. - Symptom: duplicate headers (one from origin, one from CDN) with conflicting values. Browsers may take the first or reject; assessors flag ambiguity. Fix: strip the origin header at the edge or emit from one layer only. See the platform pages for
proxy_hide_headerand Transform Rule ordering. - Symptom: legacy
X-XSS-Protectionor other retired headers cited in an old policy doc. Fix: remove them and reference the current control set — see deprecated headers and legacy browser support so the policy document matches the wire.
Rollback directive: HSTS is the one header with a client-cached lock-in. If a bad preload or an over-broad includeSubDomains breaks a subdomain, you cannot instantly recall it — browsers honour the cached max-age. The safe rollback is to serve Strict-Transport-Security: max-age=0 (which tells browsers to forget the policy on their next visit) and, if you submitted to the preload list, request removal at hstspreload.org. Every other header in this baseline is stateless: revert the config, redeploy, and the change is live on the next response with no cached residue. Stage HSTS with a short max-age (e.g. 300) before committing to a year.
Frequently Asked Questions
Does PCI DSS 4.0 explicitly require HTTP security headers by name?
No. PCI DSS 4.0 states outcomes — strong transport cryptography in Requirement 4.2.1, protection of payment-page scripts in 6.4.3 and 11.6.1, and no caching of cardholder data in 3.4.1. Security headers are the accepted implementation that produces those outcomes, and a QSA will map your curl evidence back to those requirement numbers. Present the header value alongside the requirement it satisfies.
Which single header best satisfies the anti-skimming controls in PCI 6.4.3?
Content-Security-Policy with a strict script-src (no 'unsafe-inline') plus a report-uri/report-to endpoint. The strict source list stops unauthorised scripts from executing on the payment page, and the report endpoint provides the tamper-detection and change-alerting mechanism the requirement demands. A CSP without a report destination satisfies the blocking half but not the detection half.
What evidence does a SOC 2 auditor accept for a header control?
A reproducible capture of the response headers from the production origin — a timestamped curl -I output or a DevTools Network screenshot showing the header on a real response — tied in your control narrative to the relevant Common Criteria (typically CC6.1, CC6.7, or CC7.1). Auditors want the wire value and the mapping, not a screenshot of a config file, because config does not prove the header reached the client.
Is Cache-Control: private sufficient for cardholder-data pages?
No. private prevents shared caches from storing the response but still permits the browser to write it to local disk, which does not satisfy PCI 3.4.1’s requirement that cardholder data is not retained. Use no-store on any page that renders account or payment data, and clear residual state on logout.
How do I present the header-to-control mapping to an assessor efficiently? Attach the mapping matrix from this page as a single table: header, exact required value, framework requirement number, and a pointer to the evidence file that shows the wire capture. Regenerate the evidence in CI on every deploy so the mapping is current at audit time rather than a stale snapshot, and flag any header served only from a CDN so the assessor knows which layer owns it.