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.

Header to compliance framework mapping matrix A matrix showing seven security headers down the left and three compliance frameworks across the top, with the specific requirement each header satisfies in each framework. Header PCI DSS 4.0 SOC 2 (CC) ASVS v4 HSTS 4.2.1 CC6.7 9.1.1 CSP 6.4.3 / 11.6.1 CC6.1 / CC7.1 14.4.3 / 5.3 X-Content-Type-Options 6.2.4 CC6.1 14.4.4 frame-ancestors 6.2.4 CC6.1 14.4.7 Referrer-Policy 3.x (leak) CC6.7 8.3.1 Permissions-Policy supporting CC6.1 14.4.x Cache-Control 3.4.1 / 4.2.1 CC6.1 8.2.1 Green cells are direct control satisfaction; "supporting" strengthens a control without being the sole evidence for it.
Each header traces to a named requirement in all three frameworks; this matrix is the spine of an audit-evidence package.

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
Evidence chain from wire value to audit finding A left-to-right flow showing a header captured on the wire, matched against a required value, mapped to a framework control, and recorded as pass or fail evidence. curl -I wire capture match value byte-for-byte map control PCI / SOC2 / ASVS record evidence file Two failure branches an assessor rejects: present in config, absent on 5xx -> not implemented served over invalid TLS -> not counted Evidence is the response bytes on a valid session, not the config file.
The evidence chain: a header only counts once captured on the wire, matched to its value, and tied to a named control.

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.

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.

HSTS rollback state machine versus stateless header revert A state diagram contrasting the multi-step HSTS rollback path through max-age zero with the single-step revert of stateless headers. HSTS (client-cached — cannot instantly recall): bad policy live serve max-age=0 + preload removal clients revisit cache expires cleared Stateless headers (CSP, frame, referrer, cache, nosniff): bad value live revert + redeploy live next response
Only HSTS needs a multi-step client-cache rollback; every other header reverts on the next response.

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.