Skip to content

Build1 publisher3 min readPublished

One add_header in an nginx location block discards the other four set above it

A dev.to comparison of hand-written nginx directives against delegated edge policy comes down to the inheritance and duplication rules that make a passing config file a poor description of what a client actually receives.

The Engineer · Build desk

Illustration accompanying One add_header in an nginx location block discards the other four set above it

What happened

  • A dev.to comparison by Oleksandr Kuryzhev argues nginx TLS and header hardening turns on one decision made once: whether nginx itself or a layer in front of it owns the policy.
  • When the edge and nginx both set headers, duplicate X-Frame-Options is treated as invalid and dropped, while duplicate Content-Security-Policy headers are combined into their most restrictive intersection.
  • Cipher and header guidance is revised periodically, so a config that scored an A two years ago can degrade as TLS 1.0/1.1 deprecation tightens or CAs change what they support.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Both silent failure modes are invisible in the config text, so sign-off based on a reviewed server block overstates what clients on each path actually receive.
  • cost Hand-written policy means recurring per-vhost upkeep, and that work lands on whoever inherits the ten or twenty server blocks after the original author moves on.
  • decision Naming one owner gives a service with unusual requirements a defined path, either fighting the shared policy or taking a logged exception, instead of diverging where nobody looks.
  • constraint A cipher string copied from an old post constrains nothing under TLS 1.3, so hardening cannot be evidenced from ssl_ciphers in the config alone.

nginx's `add_header` directives do not merge across nested blocks. A `location` block that sets its own `add_header` discards every header the parent `server` block set, and nginx does not combine the two lists [4]. The example in the post is five hardened headers at the server level, plus one `location /api` that adds a single header for CORS [5]. By the rule as written, the response on that path carries the CORS header and nothing else [7]. Whether you call that a loss of four headers or five depends on how you count. The behaviour is documented in the nginx headers module reference, and the check is `curl -I` against the actual endpoint rather than a read of the config file [6].

Hand-written directives buy real things: control per virtual host, no third-party trust boundary, operation air-gapped or on-prem, and a git history that answers an auditor asking who approved a change and when [9]. Kuryzhev wrote that "drift is the real cost" [10]. Across ten or twenty server blocks, the ordinary failure is a forgotten `always` flag on one `add_header`, or one vhost left behind when TLS guidance changes [8].

Delegating policy to a CDN, WAF or API gateway, or generating one snippet from a single source of truth and including it via infrastructure-as-code, changes the update from N places to one and moves TLS termination cost off the application tier [11][12]. It also creates a second writer for the same headers, and the two collision behaviours are not symmetric. Duplicate or conflicting `X-Frame-Options` values are generally treated as invalid by the browser and the header is dropped outright, with no error anywhere [14]. Duplicate `Content-Security-Policy` headers are combined, and the browser enforces the most restrictive intersection of the two policies [15]. So one collision leaves you less protected than either config claims. The other leaves you more restricted than anyone wrote. Neither is visible until you inspect the live response with curl or dev tools [24][16]. Kuryzhev starts a second caveat, that "hardened at the edge" quietly becomes an assumption, and the published text breaks off mid-sentence there [17].

Which option is right depends on who re-scans. Cipher and header guidance changes periodically. Both the Mozilla SSL Configuration Generator and NIST's TLS guidance are revised as weak algorithms are deprecated and new attack classes are published [18]. A config that scored an "A" two years ago can degrade on its own as TLS 1.0/1.1 deprecation guidance tightens or as CAs change what they support [19]. "Nobody re-scans a working proxy until something breaks it," Kuryzhev wrote [20].

For an old scan grade to still describe the box, four conditions have to hold: guidance has not tightened since, the CA still supports what you configured, no vhost has been added, and no `location` block has been added on a path you care about [25]. The first two sit outside your repository [18][19].

One detail is not about ownership at all. TLS 1.3 ignores legacy `ssl_ciphers` syntax entirely, so a string copied from an old blog post can look hardened and do nothing. The post says cipher lists should come from a maintained generator [21].

Either owner works, in my view, and the decision only becomes real when a person owns the date of the next scan. The post argues the same ordering: pick the owner first, nginx or the layer in front of it [3].

What to watch

  • Whether a re-scan gets a named owner and a date once the audit or pentest ticket closes.
  • The rest of the post's second edge caveat, which breaks off mid-sentence on what "hardened at the edge" assumes.
  • Further tightening of TLS 1.0/1.1 deprecation guidance. That tightening would force a change in every server block at once.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories