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
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

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

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.