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:

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.

The whole rule collapses to one branch: did this block write any add_header line of its own? Answer “no” and the block silently inherits the entire parent set. Answer “yes” and it inherits nothing — the count of directives is irrelevant, one is as fatal as ten. Everything that follows is a way of forcing the “yes” branch to still carry your baseline.

Decision path for whether a block inherits the header baseline A decision tree that routes a block to full inheritance when it adds no header and to zero inheritance when it adds any header. Does the block declare any add_header? no yes Inherits full parent baseline every inherited add_header emits Inherits none of them only this block's headers emit Baseline preserved no action needed Re-include or repeat baseline restore dropped headers here
The single question that governs inheritance: a block that writes no add_header inherits the whole baseline, while a block that writes even one inherits nothing and must re-establish the baseline itself.

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
}

The three fixes solve the same problem with different trade-offs. Fix 1 needs no shared file and suits a vendored config you cannot restructure, but it duplicates the baseline into every block and rots the moment the baseline changes. Fix 2 keeps one source of truth and is the right default for almost every deployment. Fix 3 is narrower: it applies when a single header’s value varies by path, letting you emit exactly one directive so re-declaration can never drop a sibling.

Comparison of the three fixes for dropped inherited headers A matrix comparing the repeat, include, and map fixes across source of truth, directives per block, and maintenance cost. Fix 1: repeat Fix 2: include Fix 3: map Single source of truth no yes yes Directives per block many include + 1 one Rots on baseline edit yes no no Best when vendored config most setups value varies
The three fixes compared: repeat duplicates and rots, the include snippet keeps one source of truth for most setups, and the map emits a single value-driven directive when the policy differs by path.

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.

For a repeatable check across many paths, loop a known-good header name over every route you care about and flag the ones that dropped it. This catches the trap without eyeballing the config:

for p in / /assets/app.css /api/health /download/report.pdf; do
  h=$(curl -sI "https://app.yourdomain.com$p" | grep -i '^strict-transport-security' || echo "MISSING")
  printf '%-24s %s\n' "$p" "$h"
done
/                        strict-transport-security: max-age=63072000; includeSubDomains
/assets/app.css          MISSING
/api/health              MISSING
/download/report.pdf     strict-transport-security: max-age=63072000; includeSubDomains

Every MISSING row is a block that re-declared add_header without re-establishing the baseline. Add the include to each, reload, and re-run the loop until no row reports MISSING. Because HSTS is inherited exactly like the other baseline headers, a path that keeps HSTS keeps the whole set, so one probe header is a reliable proxy for the baseline as a whole.

Edge Cases, Security Implications & Safe Rollback

add_header inheritance dropped when a location adds its own A configuration tree showing the server baseline inherited by a plain location but dropped by an assets location that declares its own add_header. server block HSTS + nosniff location / adds nothing inherits baseline location /assets/ adds Cache-Control baseline DROPPED response: HSTS, nosniff present response: Cache-Control only — no security
The server baseline reaches 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.