Nginx makes adding HTTP security headers refreshingly simple: a handful of add_header directives in the right place, a config test, and a reload. The details that trip people up are where those directives live and one inheritance quirk that silently drops every header inside certain blocks. This guide gives you copy-paste values and explains the rules so the headers actually reach the browser on every response.
Where do I put add_header directives?
Nginx applies add_header at three levels: the http block (global, in nginx.conf), a server block (one site), or a location block (one path). For most sites the cleanest home is the server block, so the headers cover everything that site serves. If you run many virtual hosts that should all behave identically, the http block is fine too.
Always pass the always parameter. Without it, nginx only emits the header on responses with a 2xx or 3xx status code.
The 4xx and 5xx pages — your 404s, 403s, and 500s — would ship without your security headers, which is exactly when a misconfigured browser or a probing client is poking at the edges of your app. The always flag tells nginx to attach the header regardless of status.
After editing config, validate and reload rather than restart. A reload re-reads the configuration with zero dropped connections; a restart tears the worker processes down. Run nginx -t first — if it reports a syntax error, the reload will refuse and your old config keeps running, so you never serve a broken site.
# Validate the configuration first
sudo nginx -t
# Then reload gracefully (no dropped connections)
sudo systemctl reload nginx
# or, without systemd:
sudo nginx -s reloadThe headers
Below is a complete set of add_header directives. Drop them inside your server block. Each line is commented so you know what it does and can tune the values to your needs.
server {
# ... your listen, server_name, root, etc. ...
# Force HTTPS for two years and apply to subdomains. Only enable HSTS
# once you are certain every subdomain serves HTTPS, because it is sticky.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Stop browsers from MIME-sniffing a response away from its declared type.
add_header X-Content-Type-Options "nosniff" always;
# Disallow framing of your pages (clickjacking protection). Use SAMEORIGIN
# if you legitimately frame your own pages.
add_header X-Frame-Options "DENY" always;
# Send only the origin on cross-origin requests; full URL same-origin.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Switch off powerful browser features you do not use.
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# A conservative Content-Security-Policy. Tune this to your real assets;
# deploy it in Report-Only first (see the next section).
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" always;
}A few notes on these values. The HSTS max-age of 63072000 seconds is two years; add preload only when you intend to submit to the browser preload list and you are confident about HTTPS everywhere.
X-Frame-Options: DENY and CSP frame-ancestors 'none' overlap — modern browsers honour the CSP directive, but keeping X-Frame-Options covers older clients. The Permissions-Policy list above disables geolocation, microphone, and camera; extend the empty allow-lists to match the features your site genuinely uses.
These values track the recommendations of the OWASP Secure Headers Project, which maintains the canonical best-practice value for each header.
Watch your quoting and semicolons
Each directive ends with a semicolon, and the whole header value is wrapped in double quotes. Inside CSP you will use single quotes for keywords like 'self' and 'none' — that is correct and does not conflict with the outer double quotes.
add_header directives are inherited into a child block only if that block defines no add_header of its own. The moment a location block adds even one header (say, a Cache-Control on a static-files location), it replaces the entire inherited set — and all your security headers silently vanish for that path. Fix it by re-declaring every header inside such locations, or by setting headers only at the server level and avoiding per-location add_header entirely. And remember: without the always flag, every header is dropped on 4xx and 5xx responses too.How do I roll out CSP safely?
Content-Security-Policy is the most powerful header here and the one most likely to break a working site. A policy that is even slightly too strict will block inline scripts, third-party analytics, fonts, or embedded widgets, and the page will look broken with no obvious cause. Never ship a brand-new CSP straight to Content-Security-Policy.
Instead, deploy it in Report-Only mode first. The browser evaluates the policy and reports what would have been blocked, without actually blocking anything. You watch the reports, widen the policy to cover the assets you legitimately load, and only then promote it to the enforcing header.
# Phase 1: observe, do not enforce. Nothing breaks.
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always;
# Phase 2: once reports are clean, switch to the enforcing header.
add_header Content-Security-Policy "default-src 'self'" always;Building a policy that fits your real asset map — scripts, styles, images, fonts, frames, connections — is its own task. Our Content-Security-Policy guide walks through each directive and how to tighten a policy without breaking the page.
How do I verify it works?
After reloading, confirm the headers are actually on the wire. The fastest check is curl -I, which fetches only the response headers:
curl -I https://example.com
# Look for these lines in the output:
# strict-transport-security: max-age=63072000; includeSubDomains
# x-content-type-options: nosniff
# x-frame-options: DENY
# referrer-policy: strict-origin-when-cross-origin
# permissions-policy: geolocation=(), microphone=(), camera=()
# content-security-policy: default-src 'self'; ...Crucially, test a path that hits a location block with its own add_header — and test a URL that returns a 404 — to confirm the inheritance trap and the missing always flag are not biting you. If a header is present on the homepage but absent on a static asset or an error page, you have found exactly one of those two issues.
Want to confirm every header reached the browser?
Check your security headers →Where to go next
For the full reference on what each header does, recommended values, and how they fit together, read the complete guide to security headers. Running Apache instead of, or alongside, nginx? The same headers with Apache syntax are in how to add security headers in Apache.