nginx add_header doesn't merge, and it takes your security headers with it
I set six security headers at the server level. They were present on the
homepage. They were absent on every stylesheet, script and image the site
served, and had been for as long as the config existed.
Nothing failed. No warning, no error log, no failed request. nginx -t was
happy. The headers were simply gone on a subset of responses, and the only way
to find out was to curl -I an asset URL rather than the page — which I had no
reason to do, because why would the headers be different?
The rule
add_header is an array directive. Directives like this one are inherited
from an outer block into an inner block only if the inner block sets none of
its own. The moment a location block contains a single add_header, the
entire inherited set is discarded and replaced by whatever that block declares.
Not merged. Replaced.
This is documented behaviour and it applies to add_header,
proxy_set_header, expires and several others. It reads as reasonable in the
abstract and is a landmine in practice, because the natural way to write an
nginx config is exactly the thing that triggers it.
What I had written
The server block set the headers once, for everything:
server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'none'; ..." always;
location / {
try_files $uri $uri/ =404;
}
}
Then, later and in a completely different frame of mind, I wanted long cache lifetimes on static assets. This is the canonical snippet. Every tutorial has it:
location ~* \.(css|js|svg|png|jpe?g|webp|woff2)$ {
add_header Cache-Control "public, max-age=604800, immutable";
}
That block adds one header and deletes six. Every CSS file, every image, every
font now ships with a Cache-Control and nothing else. No CSP. No
X-Content-Type-Options. No framing protection.
The homepage still had all six, because location / has no add_header of its
own and therefore still inherits. So every check I would plausibly run came
back clean.
Why always doesn’t save you
always controls whether a header is emitted for error responses as well as
successful ones. It has nothing to do with inheritance. Adding always to all
six — which I had done, and which feels like it should mean always — changes
nothing about this.
The fix: move the variance into a map, not a block
The instinct is to re-declare all six headers inside the location block.
That works, and it means every future header has to be added in two places
forever, and the day you forget is the day you’re back here.
Better: don’t create a second block at all. The only thing that actually varies
by file type is the value of one header, so compute that value with a
map and keep a
single set of add_header lines.
map $uri $edge_cache_control {
default "no-cache";
~*\.(css|js|svg|png|jpe?g|webp|woff2)$ "public, max-age=604800, immutable";
}
server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'none'; ..." always;
add_header Cache-Control $edge_cache_control always;
location / {
try_files $uri $uri/ =404;
}
}
Now there is exactly one place headers are set, one place to add the next one,
and no location block anywhere that can silently drop the lot.
A map goes in the http context. If you’re mounting a config into the stock
nginx image at /etc/nginx/conf.d/default.conf, that file is included from
inside http {} already, so a top-level map there is legal — which is
non-obvious, and worth knowing before you go restructuring the whole file to
accommodate it.
How to actually check
The bug is invisible if you only ever test the page. Test an asset:
curl -sI https://example.com/ | grep -ci 'x-content-type-options'
curl -sI https://example.com/styles.css | grep -ci 'x-content-type-options'
Two different numbers is the bug. If you want one command that will tell you about it in future, diff the full header sets:
diff \
<(curl -sI https://example.com/ | tr -d '\r' | cut -d: -f1 | sort) \
<(curl -sI https://example.com/app.css | tr -d '\r' | cut -d: -f1 | sort)
Expect Content-Type, Content-Length and ETag to differ. Anything starting
with X-, Cross-Origin- or Content-Security- showing up on one side only is
this problem.
The general shape
This is the failure mode I keep running into and the reason I started writing these down: the config was valid, the server was healthy, the status code was 200, and the thing I asked for silently didn’t happen.
There’s no exception to catch and no log line to grep. The only defence is knowing that the primitive behaves this way, and checking the response rather than the config.