skip to content

Your nginx `server` block sets `add_header Content-Security-Policy ...`, yet the header is missing from some responses. What two rules about nginx's `add_header` explain that?

level: middleimportance: should knowfreq 55%

answer

  1. inheritance is all-or-nothing per level
  2. one add_header wipes the inherited set
  3. default applies only to certain status codes
  4. always removes the status filter
  5. add appends, it does not replace

basics

~20 s

nginx's add_header is inherited only when the lower level defines no add_header of its own — one add_header in a location silently drops every inherited security header. And without the always parameter, add_header applies only to a fixed set of success and redirect status codes, so error responses carry nothing.

solid answer

~50 s

Two independent rules bite here. First, inheritance is all-or-nothing: `add_header` directives are inherited from an enclosing level **only if the current level defines none**. A `location` that adds one unrelated header — a cache tag, a CORS field — silently discards the entire set of security headers from the `server` block, and nginx logs nothing about it. Second, by default `add_header` only fires for a specific list of status codes (200, 201, 204, 206, 301, 302, 303, 304, 307, 308); the `always` parameter, available since 1.7.5, makes it apply regardless. So your CSP is present on a 200 and absent on the 500 that renders an error page. The remedy is to keep the security header set in an `include`d snippet, re-include it in every location that adds any header of its own, and mark them all `always`. Verify with `curl -I` against both a good URL and a deliberately failing one, not just the homepage.

code

nginx · 18 lines
nginx
server {
    listen 443 ssl;
    server_name www.example.com;

    include snippets/security-headers.conf;

    location / {
        proxy_hide_header Content-Security-Policy;
        include snippets/security-headers.conf;
        proxy_pass http://app;
    }

    location /static/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000";
        root /srv/www;
    }
}

go deeper

for a junior

Know that add_header attaches a response header and that nginx only inherits those directives when the inner level defines none of its own. Remember the always keyword exists.

for a middle

State both rules precisely and show the fix: keep the security set in an included snippet, re-include it wherever a location adds a header, and mark every one always.

for a senior

Diagnose it from response bytes rather than the config — compare a 200, a static path and a 404 — and handle the duplicate-header case where an upstream also emits a policy.

for a principal

Set the ownership boundary: decide whether the edge or the application emits security headers, make that a platform rule, and back it with an automated check on real responses so the next location block cannot quietly undo it.

## Rule one: inheritance is all-or-nothing nginx's documentation is explicit: `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. This is not "later wins" and it is not merging. It is replacement of the whole set. ```nginx server { add_header X-Frame-Options DENY always; add_header X-Content-Type-Options nosniff always; add_header Content-Security-Policy "default-src 'self'" always; location / { proxy_pass http://app; # inherits all three } location /static/ { add_header Cache-Control "public, max-age=31536000"; # inherits NOTHING - all three security headers are gone } } ``` One innocuous caching header in one location removed three security headers from an entire path prefix. Nothing warns you: `nginx -t` passes, the config is legal, and the header simply is not there. This is the single most common way a CSP or HSTS rollout ends up partially applied, and it is a favourite interview question precisely because it punishes people who have read the directive list but never diffed real responses. The same rule governs several other nginx directive families that take a list — `proxy_set_header` behaves the same way across contexts — so the habit generalises. ### Living with it Put the set in one file and include it wherever you break inheritance: ```nginx # /etc/nginx/snippets/security-headers.conf add_header X-Frame-Options DENY always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always; add_header Content-Security-Policy "default-src 'self'" always; ``` ```nginx location /static/ { include snippets/security-headers.conf; add_header Cache-Control "public, max-age=31536000"; } ``` Repetition is the price of the inheritance rule; an include keeps the repetition from drifting. ## Rule two: the status-code filter By default `add_header` adds the field only when the response code is one of 200, 201, 204, 206, 301, 302, 303, 304, 307 or 308. Every other status — 4xx, 5xx, and anything else — gets no header. That is worse than it first sounds. Error pages are exactly where an injected-content or clickjacking protection is most likely to matter, because error paths often render user-supplied fragments, and a 500 page served without a CSP is unprotected in the moment your application is already misbehaving. The `always` parameter, added in nginx 1.7.5, removes the filter: ```nginx add_header Content-Security-Policy "default-src 'self'" always; ``` There is very little reason to omit `always` on a security header. The one place to think is `Strict-Transport-Security`, where you genuinely do not want to assert a long max-age from a response you are not confident about — but even there, the usual advice is to send it always on the HTTPS listener and never on the plaintext one. ## Rule three, for completeness: add is not set `add_header` appends. If the upstream already sent the field, the client receives it twice, and browser handling of duplicated security headers varies — for CSP, multiple policies are intersected, which can break a page in ways that look nothing like a header problem. When you proxy to an application that may set its own security headers, decide who owns them. To let the edge own them: ```nginx proxy_hide_header Content-Security-Policy; add_header Content-Security-Policy "default-src 'self'" always; ``` If you need true replace-or-add semantics without hiding first, that is the third-party headers-more module's `more_set_headers`, not core nginx. ## Verifying Never accept "it is in the config" as evidence. Check the actual bytes on several classes of response: ```bash curl -sI https://www.example.com/ # a 200 from the app curl -sI https://www.example.com/static/x.js # a location that adds its own header curl -sI https://www.example.com/nope # a 404 ``` If the header is present on the first and absent on either of the others, you have identified which of the two rules bit you: a missing header on the `/static/` path is the inheritance rule; a missing header on the 404 is the status-code filter.

  • Which response codes does `add_header` apply to without the `always` parameter?
    200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 — a fixed list of success and redirect codes. Every 4xx and 5xx is excluded, so error pages go out without your security headers. `always`, available since nginx 1.7.5, makes the header apply regardless of status, which is what you want for essentially every security header.
  • The upstream already sends a Content-Security-Policy and nginx adds one too. What does the client get?
    Both. `add_header` appends rather than replaces, so two policies arrive and browsers intersect multiple CSPs — the effective policy is the strictest combination, which frequently breaks a page in a way that does not look like a header issue. Decide on one owner: `proxy_hide_header Content-Security-Policy;` before adding yours at the edge, or let the application own it entirely.
  • How would you keep the inheritance rule from silently dropping headers as the config grows?
    Keep the set in a single snippet file and `include` it in every context that defines any `add_header` of its own, so the repetition cannot drift. Then verify with automated checks that fetch a representative URL per location class, including a deliberately failing one, and assert on the response headers. Configuration review alone does not catch this; only response bytes do.

saying these in an interview costs you the question

  • Assuming add_header directives merge across levels
  • Thinking a later add_header just overrides the earlier one
  • Expecting security headers on 4xx and 5xx without always
  • Believing add_header replaces a header the upstream already sent
  • Verifying only the homepage and declaring the rollout done

context