skip to content

A team adds `add_header Strict-Transport-Security "max-age=31536000";` to an nginx `server` block, but the header is missing from 502 responses and from one location. Which two nginx add_header rules explain that, and from which listener is sending HSTS pointless?

level: seniorimportance: should knowfreq 42%

answer

  1. success and redirect codes only, by default
  2. the always parameter widens it
  3. one add_header at a level drops the parent's
  4. browsers ignore it over plaintext
  5. max-age is a promise you cannot recall

basics

~20 s

nginx applies add_header only to a fixed list of success and redirect codes unless the always parameter is given, and a level that defines any add_header inherits none from its parent. HSTS sent from the plaintext port 80 listener is ignored by browsers.

solid answer

~50 s

Two nginx rules bite here. First, `add_header` without `always` applies only to a fixed set of status codes — 200, 201, 204, 206 and the 301/302/303/304/307/308 redirects — so a 502 from a failing upstream carries no header; adding `always` makes it apply to every response. Second, `add_header` directives are inherited from the enclosing level **only if the current level declares none of its own**, so a `location` that adds any header silently drops every header set at `server` or `http` level. The usual fix is to define the header once at `http` level with `always` and avoid scattered per-location `add_header` directives, or to repeat them where you cannot. And HSTS must come from the TLS server: per RFC 6797 browsers ignore the field on a plaintext connection, so putting it in the `listen 80` block that redirects to HTTPS does nothing — the first HTTPS response is what installs the policy.

code

nginx · 14 lines
nginx
server {
    listen 443 ssl;
    server_name example.com;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location /api/ {
        # this location defines its own add_header, so the HSTS field above
        # is NOT inherited here and must be repeated
        add_header Cache-Control "no-store" always;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        proxy_pass http://api_upstream;
    }
}

go deeper

for a junior

Know that HSTS is delivered with add_header Strict-Transport-Security ... from the HTTPS server block, and that it tells the browser to use HTTPS for this host from now on.

for a middle

Explain nginx's two add_header rules — the default status-code list versus always, and the all-or-nothing inheritance that makes a location drop parent headers — and where in the config the field belongs.

for a senior

Demonstrate the rollout judgment: ramp max-age, audit subdomains before includeSubDomains, verify both success and error paths, and explain that there is no fast way to retract a policy already stored in browsers.

for a principal

Own the domain-wide commitment: who may enable includeSubDomains and preload for a shared apex, what inventory must exist first, and how the organisation handles a subdomain that cannot serve TLS for the policy's whole lifetime.

## The two nginx rules behind a missing header **Status-code filtering.** `add_header` attaches a field only when the response code is one of a fixed list — 200, 201, 204, 206, 301, 302, 303, 304, 307, 308. Every other code, including 4xx and 5xx, is skipped. The `always` parameter removes that restriction: ```nginx add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; ``` This matters more for HSTS than for most headers. The responses a browser sees when your upstream is unhealthy — 502, 503, 504 — are exactly the ones that would otherwise omit the policy, so a first-ever visit that lands on an error page leaves the browser with no HSTS record at all. **Inheritance is all-or-nothing.** nginx inherits `add_header` from the previous configuration level only when the current level defines none. Add a single `add_header X-Frame-Options ...` inside a `location` and every header configured at `server` or `http` level vanishes for that location — no warning, no partial merge. This is the rule that produces "HSTS is set, but this one path does not have it", and it is a specific nginx behaviour rather than anything in HTTP itself. Practical consequences: keep the header block at one level, and if a location genuinely needs its own header, repeat the inherited ones there or move them into an `include`d snippet used in both places. ## Where HSTS must and must not come from HSTS is a promise stored by the browser: for `max-age` seconds, never contact this host over plaintext again — upgrade the scheme locally before the request leaves, and refuse to let the user click through a certificate error. Two placement consequences: - **The plaintext listener is the wrong place.** RFC 6797 tells user agents to ignore a `Strict-Transport-Security` field received over a non-secure transport, and tells hosts not to send it there. Adding the header to the `listen 80;` server that issues the redirect accomplishes nothing; the policy is installed by the first response over TLS. - **The very first visit is unprotected.** A user typing the bare hostname still makes one cleartext request before any policy exists — the bootstrap gap that the HSTS preload list exists to close. ```nginx server { listen 80; server_name example.com; return 301 https://$host$request_uri; # no HSTS header here; it would be ignored } server { listen 443 ssl; server_name example.com; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; } ``` ## What the field's parameters commit you to `max-age` is a duration the browser enforces, refreshed by each response that carries the field. That cuts both ways: a long value is the point of HSTS, but a mistake then persists in every visitor's browser for that duration and you cannot reach out and clear it. The way back is to serve `max-age=0` over HTTPS for long enough that clients see it — which requires working TLS, so it is no help at all when the reason you want out is that TLS is broken. `includeSubDomains` extends the policy to every name under the host, including ones that do not exist yet and ones a different team owns. It is what turns an HSTS decision on `example.com` into an obligation for `legacy-intranet.example.com`. Inventory the subdomains before enabling it. `preload` goes further: it signals willingness to be baked into browsers' built-in lists, so clients enforce HTTPS before ever contacting you. Removal from those lists is slow and arrives only with browser releases, so treat preload as effectively irreversible on any timescale that matters during an incident. ## Rolling it out without an incident Start with a short `max-age` — hours, then days — while watching for anything that broke, then raise it once you are confident. Enable `includeSubDomains` only after auditing every subdomain, including internal tools and vendor-hosted names on your domain. Consider `preload` last, if at all. Verify with a request that shows headers on both a success and an error path, since the two follow different rules in nginx: ```bash curl -sI https://example.com/ | grep -i strict-transport curl -sI https://example.com/path-that-502s | grep -i strict-transport ``` If the second is empty, `always` is missing. If one specific path is empty while its neighbours are fine, look for an `add_header` inside that location.

  • Why is HSTS on the port 80 server block that redirects to HTTPS ineffective?
    RFC 6797 requires user agents to ignore the field when it arrives over a non-secure transport, precisely because an attacker on a plaintext connection could otherwise inject or strip the policy. The redirect itself is still necessary; the policy is installed by the first response the browser receives over TLS.
  • A team wants includeSubDomains. What would you check before enabling it?
    Enumerate every name under the domain, including internal tools, legacy hosts, vendor-hosted names and CNAMEs another team controls. Each must serve valid TLS for the whole max-age, because the browser will refuse plaintext and refuse click-through on a certificate error. Anything you cannot inventory is a future outage you cannot undo quickly.
  • How would you back out of an HSTS policy that turned out to be too aggressive?
    Serve `max-age=0` over HTTPS and wait for clients to return and pick it up — there is no push mechanism, and browsers that never come back keep the old policy until it expires. If the site is also on the preload list, removal additionally waits on browser release cycles. That asymmetry is why you ramp max-age upward slowly.

saying these in an interview costs you the question

  • add_header applies to every response by default
  • Setting HSTS on the port 80 redirect protects the first request
  • A location's add_header merges with the server's headers
  • HSTS can be revoked instantly by removing the header
  • preload is a flag you can safely turn on and off

context