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.
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 |
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 |
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.
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.
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
- Headers present on
/but missing inside alocation. Symptom:curl -sI .../api/shows none of the baseline headers. Cause: thatlocationdeclares its ownadd_header, dropping all inherited ones. Fix: move the baseline intoinclude /etc/nginx/security-headers.conf;andincludeit inside everylocationthat has its ownadd_header. Detailed walkthrough: Nginx add_header inheritance in location blocks. - Headers vanish on 403/404/500. Symptom: present on
200, absent on error pages. Fix: appendalwaysto everyadd_header. Without it, error responses ship no headers. - Duplicate headers from an upstream. Symptom:
curl -sIshowsX-Frame-Optionstwice with conflicting values. Cause: the upstream sets the header and Nginx adds it again. Fix:proxy_hide_header X-Frame-Options;in the proxiedlocation, then re-add the value you want withadd_header ... always;. See Nginx add_header vs proxy_hide_header explained. proxy_hide_headerstripped a header you needed. Symptom: an upstream’s legitimateStrict-Transport-Securitydisappeared. Fix: only hide informational headers (Server,X-Powered-By,X-AspNet-Version); never strip a security header unless you immediately re-add a hardened value.- CSP broke inline scripts. Symptom: console reports
Refused to execute inline script. Fix: stage the policy asContent-Security-Policy-Report-Only, then adopt nonces or hashes rather than'unsafe-inline'. - Safe rollback. Keep each change in version control. To revert: restore the previous config (or comment the offending directive), run
nginx -t, reload withnginx -s reload, and re-run thecurl -sIchecks above. Because Nginx reloads are graceful, no requests are dropped during 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.
Related
- Server & Platform Implementation Guides — the parent reference for all server hardening.
- Nginx add_header vs proxy_hide_header explained — strip upstream headers, then re-add hardened values.
- Nginx add_header inheritance in location blocks — the replace-not-merge trap in detail.
- Content-Security-Policy essentials — the policy you stage with the CSP header above.
- HSTS deep dive —
max-age,includeSubDomains, and preload. - Apache .htaccess & VirtualHost Hardening — the
Header always setequivalent.