Nginx Security Headers Configuration

This guide is part of the Server & Platform Implementation Guides reference and covers the complete set of HTTP security headers in Nginx: the exact add_header directives, the inheritance trap that silently drops them, and the verification commands that prove the policy reached the wire. Nginx applies headers through a small number of directives with surprisingly sharp edges — the most common production incident is not a wrong value but a header that was correct in http {} and then vanished the moment a location block added one of its own.

Threat Model & Protocol Mechanics

HTTP security headers are server-issued instructions that the browser enforces on the client side. The server emits them once per response; the browser caches or applies them per its own enforcement model. Nginx’s job is narrow and mechanical: attach the right header lines to the right responses, including error responses, without dropping them as the request flows through http → server → location scope.

Three mechanics dominate every Nginx headers deployment, and all three cause silent failures rather than loud errors.

1. add_header inheritance is replace-not-merge. Nginx inherits add_header directives from an outer block into an inner block only if the inner block declares no add_header of its own. The moment a server or location block contains a single add_header, every inherited add_header from the parent is dropped for that block. This is documented Nginx behavior, not a bug, and it is the number-one cause of “the header is there on the homepage but missing on /api/”. A child block with its own add_header does not add to the parent set — it replaces it entirely. This is covered in depth in Nginx add_header inheritance in location blocks.

2. Headers are not emitted on error responses without always. By default add_header only attaches to a fixed set of success and redirect status codes (200, 201, 204, 206, 301, 302, 303, 304, 307, 308). A 403, 404, 500, or proxy 502 response ships with no security headers unless the directive ends in the always flag. An attacker who can force an error page onto an unhardened response gets a frame-able, sniffable page. Treat always as mandatory on every security header.

3. Scope is map, server, and location — and proxies add a fourth dimension. A map block computes a value once; server sets a per-vhost baseline; location overrides per path. When Nginx proxies to an upstream, the upstream’s own headers pass through unless stripped with proxy_hide_header, producing duplicate or conflicting header lines that browsers resolve unpredictably. The relationship between add_header and proxy_hide_header is detailed in Nginx add_header vs proxy_hide_header explained.

The replace-not-merge rule reaches one level deeper than most operators expect: an add_header placed inside an if block within a location forms its own nested scope, so it drops every add_header declared in the enclosing location, not only those inherited from server. Combined with the well-known fragility of if inside location, this makes conditional headers a frequent source of gaps — prefer a map that computes the value and a single unconditional add_header over branching with if. Directives also emit in declaration order, and Nginx never deduplicates: two add_header X-Frame-Options lines in the same scope both ship, and the browser sees two field lines.

Two spec details decide whether a correct-looking directive actually takes effect. HSTS is honored only when the response arrives over a genuinely secure transport; per RFC 6797 a browser ignores Strict-Transport-Security received over plain HTTP, so setting it on a port-80 server block accomplishes nothing — deliver it only over TLS, and let the port-80 vhost do nothing but redirect. max-age=0 is the documented HSTS kill switch: it instructs the browser to expire the pinned policy immediately, which is how you unwind a bad rollout. CSP behaves the opposite way to X-Frame-Options under duplication — when a response carries more than one Content-Security-Policy header, the browser enforces all of them simultaneously and a resource must satisfy every policy to load. A duplicate CSP from an unstripped upstream therefore makes the effective policy strictly more restrictive, silently blocking assets that the origin policy alone would allow. This is why proxy_hide_header Content-Security-Policy matters even when the two policies look identical.

Mapping headers to the attacks they neutralize:

Threat Category Primary Attack Vector Header Mitigation
Protocol downgrade / SSL stripping MITM forces HTTP, intercepts plaintext Strict-Transport-Security (HSTS)
Cross-site scripting / injection Injected inline or remote script executes Content-Security-Policy
Clickjacking / UI redressing Page framed inside attacker iframe X-Frame-Options + CSP frame-ancestors
MIME sniffing / drive-by Browser reinterprets response content type X-Content-Type-Options: nosniff
Referrer leakage URL path/query sent to third parties Referrer-Policy
Excess feature attack surface Camera, mic, geolocation abused by injected code Permissions-Policy
Nginx add_header inheritance across http, server and location scope Diagram showing that an http-level add_header is inherited by a server block but dropped by a location block that declares its own add_header, unless headers are redeclared. http { } block add_header X-Frame-Options SAMEORIGIN always; inherited server { } block (no own add_header) header present: SAMEORIGIN location /a/ { } no own add_header inherits: SAMEORIGIN location /b/ { add_header ... } declares its own add_header parent DROPPED: missing
An http-level add_header reaches a location only while that location declares no add_header of its own. The moment one is added (right), the inherited set is dropped and must be redeclared.

Directive Syntax & Spec

Every security header in Nginx is set with the same directive:

add_header <field-name> "<field-value>" [always];

The always flag is the only optional token, and in a security context it is never actually optional. The table below maps each header’s directive behavior in Nginx specifically.

Directive / behavior Type Default in Nginx Security impact When to deviate
add_header without always per-response append emits on 2xx/3xx only error pages ship unhardened never for security headers
add_header with always per-response append emits on all status codes hardens 4xx/5xx and proxy errors always use it
add_header in a child block replace-not-merge suppresses all inherited add_header silent header loss per path redeclare or use include
Strict-Transport-Security server-cached policy absent enforces HTTPS for max-age shorten max-age while testing
Content-Security-Policy per-response policy absent blocks injected script/frames use -Report-Only to stage
X-Frame-Options per-response absent blocks framing in legacy UAs omit if CSP frame-ancestors covers you
X-Content-Type-Options per-response absent disables MIME sniffing no reason to deviate
Referrer-Policy per-response browser default controls referrer exposure loosen only for analytics needs
Permissions-Policy per-response all features allowed disables unused browser features enable only features you use
proxy_hide_header upstream filter upstream headers pass through strips leaked/duplicate headers scope per proxied location
Header emission with and without the always flag across status code classes A matrix showing that add_header without always emits only on 2xx and 3xx responses while add_header with always emits on error responses too. Which responses carry the header? add_header (no always) add_header ... always 2xx / 3xx 200, 301, 304 ... 4xx / 5xx 403, 404, 502 ... emitted emitted DROPPED error page unhardened emitted
Without always, a header is attached only to a fixed set of 2xx/3xx codes; the forced 404 or proxy 502 ships bare. Adding always fills the bottom-left gap that attackers target.

Common malformed-syntax gotchas: omitting the quotes around a value that contains spaces or semicolons truncates the header silently; placing always before the value is a config-parse error caught by nginx -t; and writing the directive in http {} then expecting it to survive a location that has its own add_header (it will not).

Platform-Specific Implementation

The robust pattern is a single server block that declares the full security baseline with always on every line, kept in an include file so it can be re-emitted inside any location that needs its own add_header. The block below is the complete baseline.

Nginx

server {
    listen 443 ssl;
    http2 on;
    server_name your-domain.com;

    # HSTS — enforces HTTPS for one year incl. subdomains; `always` emits it on error responses too.
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # CSP — restricts where scripts/styles/frames load from; `always` keeps the policy on 4xx/5xx pages.
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" always;

    # X-Frame-Options — legacy clickjacking guard for browsers without frame-ancestors; `always` covers error pages that could be framed.
    add_header X-Frame-Options "SAMEORIGIN" always;

    # X-Content-Type-Options — disables MIME sniffing; `always` ensures even an error body is not reinterpreted.
    add_header X-Content-Type-Options "nosniff" always;

    # Referrer-Policy — trims the Referer header on cross-origin requests; `always` keeps it consistent on every response.
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Permissions-Policy — disables unused browser features to shrink attack surface; `always` applies it uniformly.
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
}

Security impact: This block neutralizes the full first tier of client-side risk — downgrade, injection, framing, sniffing, referrer leakage, and feature abuse — on every response code. Start the Content-Security-Policy header as Content-Security-Policy-Report-Only in staging, collect violations, then rename the directive to enforce. Begin HSTS with a short max-age (such as 300) and raise it only once you have confirmed every subdomain serves HTTPS; add preload only after the long max-age is live and stable.

Apache

For the Apache equivalent of this block — Header always set with mod_headers — see Apache .htaccess & VirtualHost Hardening. The semantics differ: Apache Header set merges across scopes rather than replacing them, so the inheritance trap described above is specific to Nginx.

Cloudflare

When Nginx sits behind an edge network, push the baseline at the edge instead of (or in addition to) the origin to guarantee it reaches even cached responses; see Cloudflare Page Rules & Headers. If both layers set the same header, strip the origin’s at the edge to avoid duplicates.

When Nginx proxies, the upstream application often sets its own security headers. Left alone, Nginx forwards them and appends its own, so the client receives two conflicting X-Frame-Options lines and the browser picks one unpredictably. The safe pattern is to strip the upstream copy with proxy_hide_header, then re-assert a single hardened value with add_header ... always.

Nginx strips a duplicate upstream header then re-adds one hardened value A data flow where a client request reaches Nginx, is proxied to an upstream that returns its own header, and Nginx hides that header before adding a single hardened value back to the client. Client browser Nginx proxy_hide_header X-Frame-Options; add_header ... always; Upstream app server GET / proxy_pass resp + upstream header one hardened header
The dashed return leg carries the upstream's own header into Nginx; proxy_hide_header discards it and add_header ... always emits exactly one authoritative value, so the client never sees a duplicate.

One-line verification (any platform):

curl -sI https://your-domain.com | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'

Verification & Diagnostic Workflows

Validation has two halves: prove the config is syntactically valid before reload, then prove the headers reached the wire on both success and error responses. The figure below sequences the whole gate — never advance to a reload until nginx -t is clean, and never call a deploy verified until the missing-path check confirms the headers survive an error.

Nginx security-header verification pipeline from syntax check to CI gate A five-stage left-to-right pipeline: validate syntax, reload, check a success response, check an error response, then a CI gate that fails on any missing header. From config change to verified on the wire nginx -t syntax ok reload graceful curl 200 headers present curl 404 always check CI gate fail if missing A red stage stops the deploy; nothing downstream is trusted until it passes.
The two accent-strong stages are the ones teams skip: verifying an error response and wiring the check into CI. Both catch the missing-always regression before users do.

1. Validate config before reload. Never reload without nginx -t:

sudo nginx -t

Expected output:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Then reload with sudo nginx -s reload (or systemctl reload nginx).

2. Inspect headers on a success response:

curl -sI https://your-domain.com

Expected (abbreviated):

HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()

3. Inspect headers on an error response — this is the test the always flag exists for:

curl -sI https://your-domain.com/this-path-does-not-exist

A correctly hardened server returns HTTP/2 404 with the same six headers present. If they are missing here but present in step 2, an always flag is missing somewhere.

4. Audit the full compiled config to find every header directive and catch accidental overrides:

sudo nginx -T | grep -E 'add_header|proxy_hide_header'

5. CI/CD gate. Fail the pipeline if a critical header is absent:

#!/usr/bin/env bash
set -euo pipefail
required=(strict-transport-security content-security-policy x-content-type-options)
hdrs=$(curl -fsSI "https://staging.your-domain.com" | tr 'A-Z' 'a-z')
for h in "${required[@]}"; do
  echo "$hdrs" | grep -q "^$h:" || { echo "MISSING: $h"; exit 1; }
done
echo "all required headers present"

In browser DevTools, open the Network tab, disable cache, reload, and confirm the headers appear on the document response and on a 304 Not Modified for a static asset served from the same origin.

Troubleshooting, Misconfigurations & Safe Rollback

Frequently Asked Questions

Why do my security headers disappear in some paths but not others? A location (or server) block that contains any add_header of its own discards every add_header inherited from a parent block. Nginx replaces rather than merges. Redeclare the headers in that block, or pull them into an include file you reference everywhere.

Is the always flag really necessary if my site rarely returns errors? Yes. Without always, no security header is attached to 4xx or 5xx responses, and those are exactly the pages an attacker can often force. An unhardened 404 is frameable and sniffable. Treat always as mandatory.

Should I set both X-Frame-Options and CSP frame-ancestors? Set frame-ancestors as the authoritative control and keep X-Frame-Options: SAMEORIGIN as a fallback for older browsers that do not honor frame-ancestors. Where both are present, modern browsers obey frame-ancestors.

How do I confirm a reload actually applied the new headers? Run nginx -t, reload, then curl -sI https://your-domain.com and compare against the expected output. Add a curl against a deliberately missing path to confirm the headers survive on 404.

Why does nginx -T matter over just reading my config file? nginx -T dumps the fully resolved configuration including every include, so it reveals duplicate or shadowed add_header directives that are invisible when reading a single file.

What are the exact requirements before I add preload to HSTS? The public preload list requires max-age of at least 31536000 (one year), the includeSubDomains token, and the preload token, all served from the apex domain over HTTPS. Add preload only after that long max-age has been live and stable, because removal from the list is slow and browsers hard-fail HTTP for preloaded domains regardless of the header they currently send.

Does putting a security header in an if block work? It works but it is fragile. An add_header inside if creates a nested scope that drops the enclosing location’s own add_header directives, reintroducing the replace-not-merge gap. Compute the value in a map and emit a single unconditional add_header ... always instead of branching with if.