Nginx add_header Inheritance in Location Blocks
This guide is part of the Nginx Security Headers Configuration reference. Nginx inherits add_header directives down the configuration tree from http to server to location — but only while a child block adds nothing. The moment a child block (a nested location, a server, or an if) declares even one add_header of its own, Nginx discards every add_header inherited from its parents on that block. The replacement is silent: no warning, no error, no log line. A location that adds a single Cache-Control header drops your Strict-Transport-Security, your Content-Security-Policy, and the rest of your baseline on that exact path, and a curl -I against any other path keeps showing them, which is why the gap survives review.
Configuration Syntax & Exact Values
The rule, stated precisely: add_header is inherited from the previous configuration level if and only if there are no add_header directives defined on the current level. This is replacement, not merging.
# http or server level: the security baseline
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Annotated breakdown:
- Inheritance is all-or-nothing per block. If a
locationhas zeroadd_headerlines, it inherits the full parent set. If it has one, it inherits none and emits only that one. There is no per-header merge. - The trap is locality. The drop happens only inside the block that re-declares. Sibling locations are unaffected, so the missing headers appear on one path while every other path looks correct.
alwaysdoes not change inheritance. Thealwaysflag controls whether a header emits on error responses (4xx/5xx); it has no bearing on whether the directive is inherited. An inheritedadd_header ... alwaysis dropped just the same.
Here is the trap in its smallest form:
server {
# Baseline declared at server level
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
location / {
# No add_header here -> inherits both baseline headers. Correct.
}
location /assets/ {
# ONE add_header here -> inherits NEITHER baseline header.
# The response on /assets/ carries only Cache-Control.
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
On /assets/ the response ships Cache-Control and nothing else. X-Content-Type-Options and Strict-Transport-Security are gone — exactly the static-file path where a missing nosniff matters most.
Fix 1 — repeat the headers
The blunt fix is to re-declare every header in the child block. It works but rots: the day you add a header to the baseline you must remember every block that re-declares.
location /assets/ {
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Fix 2 — an include snippet
The maintainable fix is to put the baseline in one file and include it everywhere a block needs headers. The include expands to the same add_header lines, so the child block still “defines its own” — but you edit the baseline in one place.
# /etc/nginx/snippets/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'" always;
location /assets/ {
include snippets/security-headers.conf; # re-establishes the full baseline
add_header Cache-Control "public, max-age=31536000, immutable";
}
Fix 3 — map plus a single add_header
When the value varies by path but you want exactly one add_header directive per response, drive the value with a map and emit it once. A single header carries the whole policy, so re-declaration never silently drops siblings.
map $uri $csp_policy {
default "default-src 'self'";
"~^/assets/" "default-src 'self'; img-src 'self' data:";
}
server {
add_header Content-Security-Policy $csp_policy always;
# one directive, value chosen per request -> no inheritance surprise
}
Server-Side Configuration
The include-file pattern (security-headers.conf)
Treat one snippet file as the single source of truth and pull it into every block that must carry headers. Because each block that adds anything loses inheritance, the rule becomes simple: every block that adds a header must also include the baseline.
server {
listen 443 ssl;
server_name app.yourdomain.com;
include snippets/security-headers.conf; # baseline for plain locations
location / {
# inherits the server-level include; no add_header here
}
location /assets/ {
include snippets/security-headers.conf; # re-establish before adding
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /api/ {
include snippets/security-headers.conf;
add_header Cache-Control "no-store" always;
}
}
Per-location re-declaration
If you cannot use an include — for example a vendored config you must not refactor — re-declare the full set in every block that adds even one header, and add a comment that pins the reason so the next editor does not delete the “redundant” lines.
location /download/ {
# MUST repeat baseline: any add_header here drops inherited security headers
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Content-Disposition "attachment" always;
}
http-level placement
Declaring the baseline at the http level pushes it to every server that adds nothing, which is convenient for multi-site nodes. It does not change the trap: a server or location that adds a header still drops the http-level set. Use http-level declarations as the default, and include the snippet in any block that needs to add to it.
http {
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
server {
# inherits both http-level headers (adds nothing)
}
}
Diagnostic & Verification Steps
Reproduce the drop by requesting the affected path and a clean path side by side.
Affected path — headers missing (the /assets/ block adds Cache-Control only):
curl -sI https://app.yourdomain.com/assets/app.css
HTTP/2 200
cache-control: public, max-age=31536000, immutable
content-type: text/css
x-content-type-options and strict-transport-security are absent — dropped by inheritance reset.
Clean path — headers present (the / block adds nothing, so it inherits):
curl -sI https://app.yourdomain.com/
HTTP/2 200
x-content-type-options: nosniff
strict-transport-security: max-age=63072000; includeSubDomains
referrer-policy: strict-origin-when-cross-origin
The contrast is the diagnostic: the same header is present on one path and missing on another. After applying the include fix, re-run the affected path:
curl -sI https://app.yourdomain.com/assets/app.css
HTTP/2 200
x-content-type-options: nosniff
strict-transport-security: max-age=63072000; includeSubDomains
referrer-policy: strict-origin-when-cross-origin
cache-control: public, max-age=31536000, immutable
Find every block that adds a header so you know which ones need the include:
sudo nginx -T | grep -nE 'location|add_header'
Each location that shows an add_header but no include snippets/security-headers.conf above it is a candidate for a dropped baseline.
Edge Cases, Security Implications & Safe Rollback
ifblocks reset inheritance too. Anadd_headerinsideif ($arg_x) { ... }drops the surrounding block’s inherited headers for matched requests. Avoidadd_headerinsideif; move the logic to amapand emit one header at the outer level.- Proxied locations. A
locationwithproxy_passis still alocation: adding anyadd_headerthere drops inherited headers. This compounds withadd_headervsproxy_hide_header— you must both strip the upstream’s headers and re-establish your baseline (viainclude) in the proxied block. error_pageresponses needalways. A customerror_pageserved from an internallocationonly carries headers if thoseadd_headerlines usealways; without it the error response ships none. If the internal location also adds anadd_header, re-include the baseline there as well.- Safe rollback. Inheritance changes are non-destructive — they only affect which headers attach to responses. Revert by removing the added
include/add_headerlines, runnginx -t, and reload withnginx -s reload. The graceful reload drops no in-flight requests. Re-run the affected-pathcurl -sIto confirm the prior header set returns.
location / intact, but location /assets/ declares its own add_header and inherits none — the security headers are dropped on that path only.Frequently Asked Questions
Does Nginx merge add_header directives across levels?
No. Inheritance is replacement, not merge. A child block with any add_header inherits none from its parents; a child block with zero add_header directives inherits the entire parent set. There is no per-header combining.
Why do my headers show up on the homepage but not on /assets/?
Because the /assets/ location declares at least one add_header of its own (typically Cache-Control), which discards the inherited baseline on that path. The homepage location adds nothing, so it still inherits. Re-include the baseline in /assets/.
Does the always flag affect inheritance?
No. always only controls whether a header emits on 4xx/5xx responses. Inheritance is governed solely by whether the current block defines any add_header at all; an always directive is inherited or dropped on the same rule as any other.
Is an include enough to restore the headers?
Yes. The include expands to literal add_header lines inside the block, so the block now “defines” the full baseline plus whatever extra header you add. Editing the snippet updates every block that includes it.
Conclusion
Roll this out incrementally. In staging, run nginx -T | grep -nE 'location|add_header' to list every block that adds a header, then include snippets/security-headers.conf in each one and verify the affected paths with curl -sI against both a clean and a re-declaring location. Start HSTS at a short max-age, confirm the baseline is present on every path including /assets/ and proxied routes, then promote the include pattern to full production behind nginx -t and a graceful reload.
Related
- Nginx Security Headers Configuration — the full baseline and snippet structure this protects.
- Nginx add_header vs proxy_hide_header Explained — why proxied locations need both a strip and a re-include.
- Content Security Policy essentials — the CSP value used in the snippet and map examples.