Server & Platform Implementation Guides

Security headers are not a single configuration artifact. The same response can pass through an application framework, a reverse proxy, and a CDN edge before a browser ever parses it — and each of those layers can inject, mutate, duplicate, or silently drop a header. This reference treats the header pipeline as a system: where each platform writes headers, which layer wins a conflict, and how to verify the bytes the browser actually receives. Deep-dive configuration for individual stacks lives in the Nginx security headers configuration, Apache .htaccess and VirtualHost hardening, and Cloudflare page rules and headers guides; this page is the contract that binds them together.

Security Scope & Operational Boundaries

This reference covers the delivery and enforcement of HTTP response security headers across the four layers a response traverses, and the failure modes unique to crossing layer boundaries.

In scope:

Explicitly out of scope:

The operating assumption throughout: a header is only as trustworthy as the last layer that touched it. A correct policy emitted by your framework is worthless if a proxy strips it or a CDN replaces it with a default.

The Header Precedence Pipeline

A response is assembled progressively. Understanding where each platform writes and which writes survive is the prerequisite for every configuration decision below.

Security header precedence pipeline across deployment layers A response flows left to right from the application framework through a reverse proxy and CDN edge to the browser; each layer can add, replace, or drop headers, and the browser enforces the final merged set. App framework Helmet, Django, FastAPI, Next.js writes baseline Reverse proxy Nginx, Apache, IIS hide / re-set CDN edge Cloudflare, Vercel edge transform / cache Browser enforces the final merged set

Each layer may add, replace, or drop a header risk: forgets header risk: duplicate / drop risk: default override last value wins* *for most headers the browser uses the first value or rejects duplicates — verify on the wire

Headers are written progressively from origin to edge; a downstream layer can override an upstream one, so the only authoritative source of truth is what the browser receives.

The pipeline has one counterintuitive property: the layer closest to the browser usually wins, but the browser’s own merge rules differ per header. A CDN that appends a second X-Frame-Options does not override the origin’s — it produces two header lines, and most browsers treat the duplicate as invalid and ignore framing protection entirely. A single Content-Security-Policy emitted twice is intersected (most restrictive wins), which can silently break a working policy. There is no universal rule; you must verify each header’s on-the-wire form.

Core Threat Model

The threats below are specific to mis-layering — failures that arise from how headers are assembled across the pipeline, not from the absence of any single directive. Per-header attack analysis lives in the header references.

Threat Category Primary Attack Vector Header Mitigation
Header drop on error responses Origin returns 5xx/4xx; proxy emits the error body without the security header because it was set without always, leaving error pages unprotected for clickjacking/XSS Set every header with Nginx always / Apache Header always set; verify headers appear on a forced 500 and 404
Duplicate framing header CDN and origin both set X-Frame-Options; browser sees two lines, treats them as malformed, and applies no framing protection (regression of CVE-class clickjacking) Set framing at exactly one layer; prefer frame-ancestors in CSP and strip the legacy header downstream
Silent CSP intersection Two layers each emit a Content-Security-Policy; the browser enforces the intersection, blocking legitimate resources or weakening to the laxer common subset Emit CSP from one authoritative layer; use report-only at others, never a second enforced policy
HSTS scope downgrade Proxy re-sets Strict-Transport-Security without includeSubDomains/preload, overriding the origin’s stronger directive and exposing subdomains to downgrade Set HSTS at the TLS-terminating layer only; assert the full directive string in verification
Cache poisoning of headers A misconfigured Cache-Control lets the CDN cache a response whose security headers were keyed to a nonce or per-user context, serving a stale/incorrect policy to other users Mark per-request header responses with Cache-Control: no-store/private; never cache responses carrying per-request CSP nonces
Origin fingerprint leak Proxy or CDN forwards upstream Server / X-Powered-By because dedup was applied at the wrong layer, aiding reconnaissance Strip with Nginx proxy_hide_header / Apache Header always unset at the boundary that terminates the connection to the client; see removing X-Powered-By and Server safely
Lost privacy controls behind proxy Referrer-Policy / Permissions-Policy set at the app but the proxy uses proxy_pass with a location that re-declares add_header, discarding all inherited headers Re-assert Referrer-Policy and Permissions-Policy in every location that declares any add_header, or set them at a single authoritative layer
Cross-origin isolation broken at edge CDN strips Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy it does not understand, silently disabling cross-origin isolation and SharedArrayBuffer Set COOP/COEP at the edge layer itself or use an allowlist passthrough; verify crossOriginIsolated === true in the browser console

Phased Deployment Strategy

Roll out headers in dependency order, and at each phase decide which layer owns the header. The governing rule is layer precedence: App writes the baseline, the proxy enforces and de-duplicates, the CDN preserves or transforms, the browser enforces the merged result. Pick one owning layer per header and make the others passthrough.

Phase 1: Transport Enforcement (HSTS)

Objective. Lock the connection to HTTPS at the layer that terminates TLS — and only that layer — so no downstream re-set can downgrade the directive. The owning layer is wherever TLS terminates (commonly the CDN or the proxy, not the app).

# Nginx — set at the TLS-terminating proxy; `always` emits it on error responses too,
# so a 502 from the upstream still carries HSTS.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

# Prevent an upstream app from also setting HSTS and creating a duplicate:
proxy_hide_header Strict-Transport-Security;
# Apache — `always` applies the header regardless of response status.
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Drop any HSTS the proxied backend emitted so only this VirtualHost's value reaches clients.
Header always unset Strict-Transport-Security "expr=%{resp:X-From-Backend}=='1'"

Verification Steps:

Phase 2: Content-Security-Policy & Resource Isolation

Objective. Emit a single authoritative CSP from the layer that can compute per-request nonces (usually the app or an edge function), and ensure no other layer adds a second enforced policy that would intersect with it. Dynamic, nonce-based policies belong in the application; static policies can live at the proxy.

# Nginx — only safe for a STATIC policy with no per-request nonce.
# A nonce-bearing CSP must come from the app; do not duplicate it here.
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
# If the app sets its own CSP, hide it here to avoid intersection:
# proxy_hide_header Content-Security-Policy;
# Apache — static baseline CSP; `always` covers error pages.
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
# Use unset before set if a backend might emit its own:
Header always unset Content-Security-Policy
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"

Verification Steps:

Phase 3: Framing & Clickjacking Controls

Objective. Apply framing protection at exactly one layer to avoid the duplicate-header regression. Prefer CSP frame-ancestors; keep X-Frame-Options only for legacy clients and never set it at two layers.

# Nginx — framing owned here; strip any backend X-Frame-Options first.
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "DENY" always;
# frame-ancestors is the modern control and should be part of the single CSP (Phase 2).
# Apache — guarantee a single value by unsetting before setting.
Header always unset X-Frame-Options
Header always set X-Frame-Options "DENY"

Verification Steps:

Phase 4: Privacy & Feature Controls

Objective. Set Referrer-Policy and Permissions-Policy plus X-Content-Type-Options at one layer, and — critically for Nginx — re-assert them in every location block that declares any other add_header, because Nginx add_header directives in a location replace the entire inherited set rather than appending.

# Nginx — declare the full set together in each location that uses add_header.
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=()" always;
# Apache — mod_headers merges across contexts, but unset+set is the deterministic pattern.
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=()"

Verification Steps:

Phase 5: Legacy Header Audit & Fingerprint Removal

Objective. Strip identifying headers at the client-facing boundary. The dedup tools differ by direction: Nginx uses server_tokens off plus proxy_hide_header for upstream-originated fields; Apache uses ServerTokens Prod plus Header always unset.

# Nginx — server_tokens hides the version; proxy_hide_header drops upstream-set fields.
server_tokens off;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
proxy_hide_header X-AspNet-Version;
# Apache — ServerTokens limits the Server banner; unset removes backend leaks.
ServerTokens Prod
Header always unset X-Powered-By
Header always unset X-AspNet-Version

Verification Steps:

Phase 6: Telemetry, Caching Discipline & Drift Detection

Objective. Wire up CSP violation reporting, ensure per-request header responses are never cached, and gate deploys on a header scan. Caching discipline is the layer-crossing concern here: a CDN that caches a nonce-bearing response poisons the policy for every subsequent user.

# Nginx — never let the CDN cache a response carrying a per-request CSP nonce.
location /app/ {
    add_header Cache-Control "no-store" always;
    # ... add_header set re-declared here too (Phase 4 rule) ...
}
# Apache — same guarantee for dynamic, nonce-bearing routes.
<LocationMatch "^/app/">
    Header always set Cache-Control "no-store"
</LocationMatch>

Verification Steps:

Platform Injection Points at a Glance

Each platform exposes a different idiom for the same operation; the security-relevant detail is whether the idiom replaces, appends, or merges and whether it has an always-on-error equivalent.

Reference Node and Python baselines, set at the application layer:

// Express + Helmet — Helmet writes a default header set on every response.
// Populate the nonce BEFORE helmet() runs, and do not also set CSP at the proxy.
const helmet = require('helmet');
const crypto = require('crypto');

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      frameAncestors: ["'none'"]
    }
  }
}));
# Django settings.py — SecurityMiddleware owns HSTS, nosniff, and Referrer-Policy.
# Set HSTS here ONLY if Django terminates TLS; behind a proxy, own it at the proxy instead.
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
X_FRAME_OPTIONS = "DENY"

Security Trade-offs & Operational Considerations

Header duplication across layers is a security regression, not a redundancy. Setting the same header at the app and the proxy “for safety” produces two header lines for most fields. For X-Frame-Options the browser treats the pair as malformed and applies no protection — strictly worse than a single value. For CSP the browser intersects the two policies, so a working policy can silently break when a second layer adds even a permissive one. The discipline is one owning layer per header, with downstream layers either passing through or explicitly stripping. Verify with grep -ci, not by reading config.

proxy_hide_header is precise but easy to scope wrong. It removes a field the upstream sent before Nginx forwards the response. If you strip Strict-Transport-Security upstream but forget to re-add_header it locally, you have removed HSTS entirely. The two directives are a pair: hide the upstream value, then set the authoritative one. Stripping without re-setting is the most common cause of a header that “disappeared after we added the proxy.”

CDN overrides can be invisible. A CDN edge sits closest to the browser and can rewrite or inject headers your origin never sees in its own logs. A Cloudflare Transform Rule that sets X-Frame-Options will coexist with — not replace — an origin header unless you also remove the origin’s, producing a duplicate. Worse, some edges strip headers they do not recognize, which historically broke COOP/COEP cross-origin isolation deployments. The only reliable test is curl against the public edge hostname, never against the origin.

Cache poisoning of headers ties caching policy to header correctness. A per-request CSP nonce is valid for exactly one response. If the response is cacheable, the CDN serves the same nonce — and thus a CSP that only matches one user’s inline scripts — to every subsequent visitor, breaking the page for everyone but the first. Any route emitting a nonce-bearing CSP must carry Cache-Control: no-store or private. The inverse trade-off: static policies are cacheable and belong at the CDN for performance, so the architectural choice of nonce-based vs hash-based or static CSP directly determines which layer owns the header.

The always flag changes the protected surface, not just behavior. Without it, Nginx and Apache emit security headers only on successful (2xx/3xx) responses. Error pages — 404, 500, 502 — are exactly the responses an attacker probes and the ones most likely to render attacker-influenced content. Omitting always leaves your most-attacked responses unprotected while every audit of the homepage passes. Always set always; always verify against a forced error, as documented in security header auditing and compliance.

Verification is the only ground truth. Configuration files describe intent; the browser enforces bytes. Every phase above ends in a literal curl/grep check for a reason: layer interactions are too subtle to reason about statically. Bake the checks into CI so that a proxy change, a framework upgrade, or a CDN default cannot silently undo the policy. The full methodology — curl, openssl, testssl.sh, grading tools, and CI gates — lives in the auditing and compliance reference.