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.

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

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.