The nginx rule that quietly drops your security headers
In nginx, add_header is only inherited when the child level defines none of its own. One cache header in a location removes HSTS and nosniff from it.
A security scan comes back with a short list: the stylesheets and the scripts on a site are served without X-Content-Type-Options, without a content security policy and without HSTS. The pages themselves are clean. The config has the headers written once at server level, where they should be, and nginx -t is happy. Nothing is misspelled and nothing is commented out. The headers are gone from those paths because of an inheritance rule that most people meet for the first time on a day like this.
What actually happens
nginx documents the behaviour in one sentence: 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. It is not "merge the lists". It is all or nothing, per level.
The levels are http, then server, then location, and an if block inside a location counts as its own level too. So a config that looks completely reasonable can be losing headers:
server {
listen 443 ssl;
server_name example.com;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(css|js|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}The asset location defines one add_header, so the four at server level do not reach it. Every stylesheet, every script and every font now goes out with a cache header and nothing else. A missing nosniff on a JavaScript file is exactly the case that header exists for, and it is the file most likely to be served from a path someone else can write to.
The second half of the rule is the always parameter. Without it, nginx adds the header only to responses with a limited set of status codes, mostly 200, 201, 204, 206 and the redirects. Your 404 page, your 500 page and every rate limited response go out bare. That matters, because an error page is still a page that renders HTML in a browser.
The reason nobody notices is that nothing fails. The config test passes, the site loads, the pages you check by hand are the pages that still have the headers. The gap is on the paths nobody opens directly.
How to see it
Check one path at a time and compare. The page and the stylesheet come from the same server block, so any difference between them is the inheritance rule at work:
curl -sI https://example.com/ | grep -i 'strict-transport\|content-type-options\|referrer-policy\|content-security'
# strict-transport-security: max-age=31536000; includeSubDomains
# x-content-type-options: nosniff
# referrer-policy: strict-origin-when-cross-origin
# content-security-policy: default-src 'self'
curl -sI https://example.com/assets/app.css | grep -ci 'strict-transport\|content-type-options\|referrer-policy\|content-security'
# 0Zero is the answer you are looking for. Do the same for a path that returns an error, because that is where the missing always shows up:
curl -sI https://example.com/no-such-page | grep -ci 'content-security-policy'Reading the config is the other half. Every add_header below the server level is a place where inheritance stops, so list them:
grep -rn 'add_header' /etc/nginx/ | grep -v '^\s*#'If a location appears in that output and you did not intend it to redefine the whole header set, it is redefining the whole header set.
The fix
There are three ways out and they suit different situations.
The first is to stop using add_header for the thing that caused the problem. Cache lifetime has its own directive, and expires does not participate in the add_header inheritance rule at all:
location ~* \.(css|js|woff2)$ {
expires 1y;
access_log off;
}That sends Cache-Control: max-age=31536000 and an Expires header, and the four security headers from the server level are inherited as normal. It is the cheapest fix, and it is enough whenever all you wanted was caching. If you need immutable in the value, you are back to add_header and back to the problem.
The second is to accept the rule and repeat yourself deliberately. Put the headers in one file and include it at every level that defines any header of its own:
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;server {
include snippets/security-headers.conf;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(css|js|woff2)$ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}One file, one value per header, repeated where the language requires it. The cost is that a new location block written by someone else will forget the include, which is why the check in the next section belongs in your deploy script and not in your memory.
The third is the headers module that replaces rather than appends, if you are already building nginx with third party modules. It solves the inheritance problem properly, and it is a large change to make for four headers, so it is worth it only if you are hitting the duplicate header problem as well.
Whichever you pick, put always on every security header while you are in there.
How to check it worked
Check a set of paths, not the home page. This runs through the paths that behave differently and prints what each one is missing:
paths="/ /about/ /assets/app.css /assets/app.js /no-such-page"
required="strict-transport-security x-content-type-options referrer-policy content-security-policy"
for path in $paths; do
headers=$(curl -sI "https://example.com$path" | tr 'A-Z' 'a-z')
missing=""
for h in $required; do
echo "$headers" | grep -q "^$h:" || missing="$missing $h"
done
printf '%-22s %s\n' "$path" "${missing:-ok}"
doneBefore the fix, the two asset paths and the error page print a list. After it, every line prints ok. Keep the script, add a path to it whenever you add a location block, and run it after each deploy. It takes under a second and it is the only thing that catches the next person's add_header.
What to watch out for
- Duplicate headers are the other failure mode.
add_headerappends, so a header set at two levels goes out twice, and for a content security policy the browser enforces the intersection of both policies. The page breaks in a way that looks like a policy typo rather than a duplicate. A strict policy is already delicate enough on its own when you are serving an inline first paint. - An
ifblock inside a location is a level, so a singleadd_headerinsideifdrops the rest for the requests that match it. - When nginx proxies to an application that sets its own headers, both sets arrive at the browser. Decide in one place who owns each header and strip it in the other.
- A hosting panel that owns the vhost file will re-render it and remove your include lines at the next certificate renewal. If the file has a generated banner at the top, put the change where the panel reads it from rather than in the file.
- A CDN in front can add, drop or reorder headers, so run the verification against the public address, not against the origin.
- The same class of mistake hides elsewhere in a server block. A
rootin the wrong place changes whattry_filesresolves and can expose a dotfile to the internet, also without any error at config test time.
The general lesson is about configuration languages that merge by replacement rather than by union. nginx is explicit about it in the documentation and silent about it at runtime, which is the worst combination for a header you set once and never look at again. When a directive is inherited, write down which level owns it, and verify the result on the paths that have their own block rather than on the one path everybody opens. A check that walks a list of paths costs a second per deploy and turns an invisible rule into a visible one.
Questions and answers
- Why do my nginx security headers disappear on some paths?
- Because add_header is inherited from the previous configuration level only when the current level defines no add_header at all. As soon as a location block contains one add_header, every add_header from the server or http level stops applying inside that location. The config still passes nginx -t, since this is documented behaviour rather than an error.
- Does the expires directive break add_header inheritance too?
- No. Inheritance is decided by the presence of add_header directives at the current level, and expires is a different directive. Setting cache lifetime with expires lets a location keep the security headers it inherits, which makes it the cheapest fix when all you wanted was caching.
- What does the always parameter on add_header do?
- Without always, nginx only adds the header to responses with a small set of status codes, mostly 200, 201, 204, 206 and the redirects. Error responses such as 404 and 500 go out without it. Adding always makes the header apply to every response, which is what you want for security headers.
- What happens if the same header is set at two levels?
- add_header appends, so the response carries the header twice. For a content security policy the browser then enforces the intersection of both policies, which is stricter than either one and often breaks the page. Check for duplicates whenever you add a header include in more than one place.
- How do I test security headers on every path?
- Request a representative set of paths with curl, including the root, a subpage, a stylesheet, a script and a missing page, and check the response headers of each one against a required list. Run that check after every config change, since one new location block is enough to undo it.