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:
- Header injection points and override semantics for Nginx, Apache, IIS, Cloudflare, Node/Express (Helmet), FastAPI/Django, and Vercel/Next.js.
- The precedence chain — application → reverse proxy → CDN → browser — and how a header set at one layer is preserved, replaced, or duplicated downstream.
- The
alwaysflag (Nginx/Apache) and equivalent “emit on every status” semantics that determine whether a header survives error responses. - Header de-duplication: detecting and resolving the comma-joined or repeated-header artifacts produced when two layers set the same field.
- Verification methodology — the literal
curl/opensslcommands and CI gates that confirm the on-the-wire result, documented in detail under security header auditing and compliance.
Explicitly out of scope:
- The semantics, directive grammar, and threat model of each individual header. Those belong to the header references — Content-Security-Policy, HSTS, X-Frame-Options, Referrer-Policy and Permissions-Policy, cross-origin isolation, and Cache-Control and Clear-Site-Data — and are referenced here only where layer placement changes the outcome.
- TLS cipher selection, certificate issuance, and PKI. Transport hardening is assumed already complete; this reference enforces HSTS as a header, not the handshake beneath it.
- Application-layer authentication, CORS authorization logic, and WAF rule authoring. CORS appears only where its headers collide with security headers at a shared layer.
- Request headers. This reference is concerned exclusively with response headers that the browser enforces.
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.
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.
- Nginx —
add_header ... always. Thealwaysflag is mandatory for error coverage.add_headerin alocationreplaces the inherited set;proxy_hide_headerremoves an upstream field before re-setting. See add_header vs proxy_hide_header and add_header inheritance in location blocks. - Apache —
Header always set.mod_headersmerges across contexts; the deterministic pattern isunsetthenset. See mod_headers best practices and setting CSP in .htaccess. - IIS —
<httpProtocol><customHeaders>inweb.config. Headers set here apply to all responses including error pages by default; remove an inherited header with<remove name="..."/>before<add>to avoid duplicates from parent web.config inheritance. - Cloudflare — Transform Rules (declarative) or Workers (programmatic). The edge can override origin headers and is the right place for nonce-free static policy. See Transform Rules for custom security headers and Workers for security headers.
- Node/Express with Helmet —
app.use(helmet(...)). Helmet sets a sane default set on every response; per-request nonces require a middleware that populatesres.localsbefore Helmet runs. See configuring CSP with Helmet and nonces. - FastAPI / Django — Django’s
SecurityMiddlewareplus settings flags, or a FastAPI/Starlette middleware that mutatesresponse.headers. See Django SecurityMiddleware vs custom headers, setting CSP in Django middleware, and adding security headers in FastAPI middleware. - Vercel / Next.js —
headers()innext.config.js,headersinvercel.json, or per-requestmiddleware.ts. The build-time array and the runtime middleware can collide; pick one owner per header. See next.config.js headers array setup, vercel.json vs next.config.js, and CSP with nonces in Next.js middleware.
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.
Related
- Nginx security headers configuration —
add_header always,proxy_hide_header, and location-block inheritance. - Apache .htaccess and VirtualHost hardening —
mod_headers,Header always set/unset, and CSP in.htaccess. - Cloudflare page rules and headers — edge Transform Rules and Workers for header injection and override.
- FastAPI and Django security middleware —
SecurityMiddleware, settings flags, and custom middleware injection. - Node.js Express Helmet configuration — Helmet defaults, per-request nonces, and override semantics.
- Vercel and Next.js header management —
next.config.jsheaders,vercel.json, and middleware precedence.