Symptom
Security headers set at server (or http) level appear on some URLs and are silently absent on others. A scanner or curl -I shows, for the same server block:
== /plain
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000
== /cached
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
nginx -t is clean; there is no warning anywhere.
When it happens
server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=31536000" always;
location /plain { return 200 "plain\n"; }
location /cached { add_header Cache-Control "public, max-age=60"; return 200 "cached\n"; }
}
Any location (or nested location, or if in location) that has at least one add_header of its own loses all inherited add_header lines. Typical culprits: a Cache-Control for static assets, a CORS header on an API path, a no-store on an auth path. The more locations a config grows, the fewer pages keep the server-level headers. All nginx versions behave this way by default (add_header_inherit on is the default even where the new directive exists).
Cause
Documented inheritance rule of ngx_http_headers_module: add_header directives "are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level." It is replace, not merge.
Fix
Portable (any nginx version): put the shared headers in a snippet and include it in every location that sets its own headers (and at server level for the rest):
# /etc/nginx/snippets/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=31536000" always;
server {
include /etc/nginx/snippets/security-headers.conf;
location /cached {
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "public, max-age=60";
return 200 "cached\n";
}
}
nginx ≥ 1.29.3 (e.g. the 1.30 stable line): the add_header_inherit directive makes inheritance additive:
location /cached {
add_header_inherit merge;
add_header Cache-Control "public, max-age=60";
}
Reproduced on 1.30.5: /merge returned Cache-Control and all three server-level headers. Per the nginx docs, add_header_inherit merge; placed at http or server level is itself inherited by all nested levels (only the location-level form was tested here). On 1.27.5 the same config fails nginx -t with unknown directive "add_header_inherit" — check nginx -v on every environment before relying on it.
Another option is to set the security headers in the application (one response hook), so they do not depend on the proxy's location layout; we did both — application hook for per-response values like a CSP nonce, snippet includes in nginx for HSTS.
Verify
Check headers on one URL per location block, not just the home page:
for p in / /assets/app.js /api/health /login; do
echo "== $p"; curl -sI "https://your-host$p" | grep -iE 'strict-transport|x-frame|x-content-type|cache-control'
done
Make it a test: for each location in the config, assert the security headers are present.
Notes
always is a separate issue: without it, add_header applies only to 200, 201, 204, 206, 301, 302, 303, 304, 307, 308 responses (nginx docs) — error pages then lack the headers even where inheritance works. Reproduced: a location with add_header Cache-Control "no-store"; (no always) returning 404 sent none of the headers.- When both the application and the proxy set the same header, clients receive it twice (seen with
Cache-Control through our edge). Tests that count header lines should not expect exactly one; decide which layer owns each header.
The full body — free, open to anyone, no key.
Source: Found during WITAN's own security-header work (2026-09): HSTS and the framing/sniffing headers had never reached most pages; reproduced on 2026-09-30 with nginx 1.30.5 and 1.27.5 (official alpine images), and the add_header_inherit directive checked against the nginx documentation.