Two paths, one config file, different headers
I run a small portal behind nginx on a single VPS. I check that it works by opening the page, which is why a header problem lived there for weeks without me noticing it.
When I finally compared response headers with curl, the root page returned six of them:
x-frame-options: SAMEORIGIN x-content-type-options: nosniff x-xss-protection: 1; mode=block referrer-policy: strict-origin-when-cross-origin permissions-policy: camera=(), microphone=(), geolocation=() strict-transport-security: max-age=31536000; includeSubDomains The health endpoint on the same server returned none. Neither did /robots.txt, /favicon.ico, the 401 error page, or two preview folders I publish for clients. Same server block, same file, same nginx 1.24.0. The config reads as if the headers apply everywhere, because they are written once at the server level and nothing in those locations removes them.
add_header appends inside a level and replaces across levels
I stopped guessing and built a throwaway config on port 8099 to see the behaviour in isolation. The server level sets two headers, and each location does one small variation:
server { listen 127.0.0.1:8099; add_header X-Parent "from-server" always; add_header X-Shared "from-server" always; location = /inherit { return 200 "inherit\n"; } location = /override { add_header X-Child "from-location" always; return 200 "override\n"; } location = /same-name { add_header X-Shared "from-location" always; return 200 "same-name\n"; } } The responses, with the boring lines removed:
### /inherit X-Parent: from-server X-Shared: from-server ### /override X-Child: from-location ### /same-name X-Shared: from-location /override adds one header and loses both inherited ones. /same-name does not merge the two values into from-server, from-location, because the parent set was already discarded: the child value is the only one on the wire.
That is the whole rule, and it has two clauses that pull in opposite directions:
- inside the same level,
add_headerappends and never deduplicates; - across levels, the child does not append to the parent. If the child declares any
add_headerat all, the parent's entire set is dropped.
The nginx documentation states this in the add_header section, in one sentence that is easy to read past: these directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level.
The trap is that cache settings are add_header too
My locations did not look like they were touching security. They were setting cache policy:
location ~* \.(jpg|jpeg|png|gif|webp|avif|ico|svg|woff|woff2)$ { add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Expires "31536000" always; etag on; access_log off; expires 1y; } Three add_header lines for caching, and the six security headers from the server level are gone for every image on the site. The pages a browser fetches on its own, favicon and robots, lost them the same way. So did the 401 page that nginx generates when basic auth rejects a request, which is the first response some visitors ever see.
Here is the part that makes this expensive to find: the missing headers produce no error, no log line, and no visible symptom. Everything renders. A scanner that reads only the root page reports six good headers and passes.
The other direction: duplicated headers on the wire
While measuring, I found the opposite defect in the same config, and this one is visible in the output of any image request:
expires: Thu, 11 Nov 2027 20:02:17 GMT cache-control: max-age=31536000 cache-control: public, max-age=31536000, immutable expires: 31536000 Two Cache-Control headers and two Expires headers on one response. The first pair comes from the expires 1y directive, which writes its own header values. The second comes from my add_header lines. nginx appends both, because appending is what it does inside a level.
Two headers with the same name are legal HTTP and ambiguous for the client. Which one a proxy or browser honours depends on the implementation, so immutable may be ignored, or the invalid Expires: 31536000 may be the value that wins. An invalid date in an Expires header has no defined meaning, and browsers have historically treated it as "expire now".
The same append behaviour shows up on the health endpoint, which sends two Content-Type lines at the origin:
HTTP/1.1 200 OK Content-Type: application/octet-stream Content-Length: 8 Content-Type: text/plain The first comes from return 200 "healthy\n", the second from add_header Content-Type text/plain. It happens to work, because most clients take the last value, and Cloudflare compresses the pair into a single content-type: text/plain before the response reaches a browser. That makes the defect invisible from outside and still present on the origin, which is why I check with --resolve against 127.0.0.1 instead of trusting what the public URL shows.
The fix that survives a new location
Repeating six add_header lines in every location is a maintenance problem, and the day someone adds a location without them the hole reopens. A single snippet does the job:
# /etc/nginx/snippets/security_headers.conf add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; and every location that declares its own add_header includes it:
location ~* \.(jpg|jpeg|png|gif|webp|avif|ico|svg)$ { add_header Cache-Control "public, max-age=31536000, immutable" always; include /etc/nginx/snippets/security_headers.conf; expires 1y; } Two details in there matter. The always parameter makes the header apply to error responses as well, without it a 404 or a 500 ships without the set. And the expires directive should be removed whenever Cache-Control is written by hand, since leaving both is what produced the duplicated headers above.
The check that would have caught it
Reading the config file does not answer this question. The answer comes from the responses, one per location, with the required header names counted:
import subprocess HOST = "example.com" REQUIRED = [ "strict-transport-security", "x-frame-options", "x-content-type-options", "referrer-policy", ] PATHS = ["/", "/health", "/robots.txt", "/favicon.ico", "/401.html", "/css/style.css", "/data/index.json"] for path in PATHS: raw = subprocess.run( ["curl", "-s", "-o", "/dev/null", "-D", "-", "--resolve", f"{HOST}:443:127.0.0.1", f"https://{HOST}{path}"], capture_output=True, text=True, check=True, ).stdout.lower() counted = {name: raw.count(f"\n{name}:") for name in REQUIRED} missing = [name for name, n in counted.items() if n != 1] duplicated = [f"{name} x{counted[name]}" for name, n in counted.items() if n > 1] print(path, "missing:", missing or "none", "duplicated:", duplicated or "none") The n != 1 comparison is deliberate. Counting only for presence accepts the duplicated case, which is the second half of the problem. Pointing curl at 127.0.0.1 keeps the CDN out of the measurement, because a CDN can add a header that the origin never sent and collapse a duplicate that the origin did send.
What is still wrong in my own config
The snippet is in place for the locations that serve the site, and the four short paths and two preview folders still have no security headers. I know which lines to add and the change is small. The reason I trust the list is that the check above produced it from live responses instead of from my reading of the file.
There is one more honest limit: HSTS on a domain that is already HTTPS-only changes little, and X-XSS-Protection is obsolete in current browsers. The headers that still do work are X-Content-Type-Options, Referrer-Policy, Permissions-Policy and the frame policy. The value of the exercise was not the six headers. It was finding out that a cache setting, written three lines below them, could turn them off on most of the site without a single symptom.
Se esse tipo de armadilha já te pegou, comenta aí qual foi o teu caso. Eu coleciono esses, porque cada um deles me poupou um deploy depois. Se você mantém nginx em produção, vale rodar a checagem acima hoje: são sete URLs e trinta segundos.

