Build1 distinct publisher3 min readPublished
A security-header baseline meant as a fallback for apps that set none of their own went out to every generated vhost. Because nginx appends rather than sets, and browsers refuse to merge a contradictory pair, it removed protection instead.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
The useful question is where the information lives at the moment the decision has to be made. When nginx evaluates `add_header`, the upstream response already exists and its headers are readable as `$upstream_http_*` [13]. What is missing is a way to branch on them at that point. `if` is evaluated during request processing, before there is a response to inspect [7], and `add_header` accepts no condition and has no `if_not_present` form [6]. So the conditional has to leave the directive and move into the value: the `add_header` line stays unconditional and a `map` computes what it emits, which is why the corrected block passes `$fallback_xfo` where the first cut passed the literal `SAMEORIGIN` [12][4].
The two failures behave nothing alike. On the upstream path, three headers came back twice [4][3] but only one pair conflicted: `X-Content-Type-Options` and `Referrer-Policy` were duplicated with matching values, and only `X-Frame-Options` arrived as `DENY` next to `SAMEORIGIN` [1][3]. On the fallback path the loss was total, because a nested `add_header` replaces the inherited set rather than merging with it [8], so one `Retry-After` line in `@app_unavailable` took all three baseline headers off that response [9][2].
Whether the CSP escape hatch covers you is a property of your fleet, not of your config. On the host that got caught, `Content-Security-Policy: frame-ancestors 'none'` still held, and CSP takes precedence over `X-Frame-Options` in every modern browser [15]. That only helps applications which already set CSP, and those are the applications that did not need a baseline. The ten-year-old PHP being moved off a managed panel that used to set headers on its behalf [17] is the population this template exists for, and per the author it is the population left worse off than before the change ran [16].
Worth sitting with the verification story too. Tests were green and the config was valid [18]; neither fact describes the bytes on the wire. `nginx -t` parses, it does not curl. The author checked the live response by hand and found the duplication [18]. One more default belongs in the same read: without `always`, nginx omits the header from 4xx and 5xx responses [14], which is precisely the class of response a startup fallback location produces [9].
For the number to transfer to your generator, one thing has to be true: some application in your fleet sets a header your template also sets, with a different value. If you control every app, the cheaper design is to let the vhost own the header outright and make middleware redundant. This author does not control the apps [1], and that is the constraint that makes a per-header `map` the correct cost rather than an overreaction.
Ranked by verification strength, evidence, and original report placement.
The author maintains a system that generates nginx server blocks for the applications it deploys, and those applications are not his own.
The first version rendered into every generated server block was: server_tokens off; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always.
Within the hour, production was answering with two X-Frame-Options values: DENY set by the application's own middleware and SAMEORIGIN added by the baseline.
A curl check of the deployed host showed every baseline header returned twice: x-content-type-options: nosniff twice, x-frame-options DENY and SAMEORIGIN, referrer-policy: strict-origin-when-cross-origin twice.
Two contradictory X-Frame-Options values do not resolve to the stricter one; there is no merge rule, and browsers that receive an unparseable set of directives for that header treat the header as absent, so the correctly set DENY became nothing.
add_header adds a field to the response, does not replace an existing one, and there is no add_header ... if_not_present flag.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · September 3, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
Build concurrency on one VPS is a division problem, and the app you serve pays the remainder1 distinct publisher
build
A one-second DNS blip crashed nginx, but no auto-restart left it down for seven hours until manual fix1 distinct publisher
build
SSE in Go breaks twice before your handler runs: an illegal header, then a 30-second timeout1 distinct publisher
build
EF Core's defaults pass code review, then meet the second replica1 distinct publisher
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
One paste, no second pair of eyes
The strongest thing here is six lines of curl output, and they do carry the central point: DENY and SAMEORIGIN arriving together on the same response. But the host is unnamed, the nginx version unstated, and the two claims that turn a duplicate header into a security regression — that browsers treat a contradictory pair as no header, that CSP always outranks X-Frame-Options — are assertions with nothing beside them. The config mechanics are the sturdy half; the browser behaviour is the half a reader has to take on trust.
One operator's fleet, self-reported
Adoption amounts to a single engineer shipping a change to vhosts he generates and then unshipping it the same hour. There is no second deployment, no count of affected sites, no downstream user reporting the same behaviour, and the fleet is described only in qualitative terms. That is enough to establish the change happened in production and nowhere near enough to call the map pattern an emerging practice.
Deflates itself, then over-generalises
Give the post credit: it undercuts its own alarm two paragraphs after raising it by admitting CSP still blocked the framing on the host in question. The stretch is elsewhere — 'every modern browser' and 'browsers treat the header as absent' are universal statements resting on one unnamed box, and the worse-off-than-before conclusion is extended to a class of applications none of whose responses were checked. Meanwhile the finding with the widest blast radius, a nested location silently stripping every inherited header from the page operators actually stare at, sits below the headline rather than in it.
Nothing on sale but the byline
This is a first-person account of the author's own mistake on a developer-blogging platform: no product, no vendor, no funding round, nobody's tool positioned as the answer. What that leaves is reputational — a post-mortem written to be read, which rewards a memorable framing ('hardening removed protection') over a hedged one, and rewards publishing the fix quickly over verifying the browser claims that make the fix urgent. Mild pull, and it points at generalisation rather than fabrication.
Trust the config, verify the browsers
Split the story in two and confidence splits with it. The nginx half — append not set, no conditional, nested blocks replacing the parent set, always on error responses, maps on $upstream_http_* — is checkable on any machine in ten minutes and internally consistent with the output shown. The security half depends on browser behaviour nobody here tested, on a single unnamed host, from a single publisher, in an account that breaks off mid-sentence while listing the caveats of the recommended fix.