Adding Nginx security headers takes a few lines of config, but two quirks catch out almost everyone: headers silently disappear inside location blocks, and by default they're missing from error pages. This guide gives you a complete Nginx security headers config you can paste and adapt, explains each header in plain terms, shows how to avoid the add_header inheritance gotcha, and ends with a testing routine so you know the headers actually reach browsers. The directive behaviour below was checked against the official nginx documentation in October 2026.
Key takeaways
- Add the always parameter to every add_header line, otherwise Nginx leaves security headers off 404 and 500 error pages.
- Any add_header inside a location block cancels all inherited headers, so re-include your security headers snippet in that block.
- Start CSP in report-only mode and keep X-Frame-Options alongside frame-ancestors, as older browsers still rely on it.
- Use same-origin-allow-popups for Cross-Origin-Opener-Policy, because same-origin can break OAuth sign-in and payment popups.
The complete Nginx security headers config
Put the headers in a separate snippet file so you can include them wherever you need them. On Debian and Ubuntu, /etc/nginx/snippets/ is the usual home.
# /etc/nginx/snippets/security-headers.conf
# Force HTTPS for a year (only once every subdomain works over HTTPS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Stop MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Clickjacking protection for older browsers (CSP frame-ancestors below is the modern control)
add_header X-Frame-Options "SAMEORIGIN" always;
# Send only the origin to other sites, full URL within your own
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Switch off browser features you don't use
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=()" always;
# Isolate your window from cross-origin popups (see notes on payments and OAuth)
add_header Cross-Origin-Opener-Policy "same-origin-allow-popups" always;
# Only same-site pages may load your resources
add_header Cross-Origin-Resource-Policy "same-site" always;
# Start CSP in report-only mode, then switch to Content-Security-Policy
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'" always;
Then include it in your HTTPS server block and turn off the version number:
server {
listen 443 ssl;
server_name example.com www.example.com;
server_tokens off;
include snippets/security-headers.conf;
# ... ssl_certificate, root, locations
}
Use always on every security header. Without it, Nginx only adds the header to a fixed list of success and redirect status codes, so your 404 and 500 pages go out unprotected.
If you'd rather not write this by hand, our security headers generator builds Nginx, Apache, Netlify or PHP config in the browser, and the CSP generator helps with the policy itself.
What each header does and which value to use
| Header | Recommended value | What it stops |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains |
Downgrade to HTTP, cookie theft on public Wi-Fi |
| Content-Security-Policy | A tested allowlist of sources | Injected scripts (XSS), unwanted framing |
| X-Content-Type-Options | nosniff |
Browsers treating uploads as scripts |
| X-Frame-Options | SAMEORIGIN or DENY |
Clickjacking in older browsers |
| Referrer-Policy | strict-origin-when-cross-origin |
Full URLs (with tokens or search terms) leaking to other sites |
| Permissions-Policy | geolocation=(), camera=(), microphone=() |
Embedded scripts using powerful APIs |
| Cross-Origin-Opener-Policy | same-origin or same-origin-allow-popups |
Cross-origin windows scripting yours |
| Cross-Origin-Resource-Policy | same-site |
Other sites embedding your resources |
Strict-Transport-Security (HSTS)
HSTS tells browsers to use HTTPS for your domain for max-age seconds. Browsers ignore it on plain HTTP, so it only goes in the 443 server block. Don't add includeSubDomains until every subdomain serves valid HTTPS, and don't add preload unless you've read up on the commitment. Our HSTS guide has a staged rollout plan.
Content-Security-Policy and frame-ancestors
CSP is the strongest header and the one most likely to break things. Start with Content-Security-Policy-Report-Only, open your key pages with the browser console open, and add the sources that are reported (analytics, fonts, payment widgets). When the console is quiet, rename the header to Content-Security-Policy.
The frame-ancestors directive controls who can embed your pages in a frame. Under the CSP Level 3 specification, an enforced frame-ancestors takes priority over X-Frame-Options, so keep both: modern browsers use CSP, older ones fall back to X-Frame-Options. MDN notes that ALLOW-FROM is obsolete and that X-Frame-Options in a <meta> tag has no effect, so it must be sent as a header. While your CSP is still report-only, X-Frame-Options is your only enforced framing protection, which is another reason to keep it. Our CSP guide and clickjacking guide go further.
X-Content-Type-Options
nosniff tells the browser to trust your Content-Type header instead of guessing. It's safe for virtually every site, provided Nginx's mime.types is included (it is in the stock nginx.conf).
Referrer-Policy
strict-origin-when-cross-origin is the value OWASP recommends. Other sites see https://example.com/ rather than the full path, and nothing is sent when going from HTTPS to HTTP. Analytics on your own domain still get full URLs.
Permissions-Policy
An empty allowlist () disables a feature for your page and anything embedded in it. Only list features you're sure you don't use. If your site uses Stripe, Apple Pay or Google Pay, don't disable payment: remove it from the list, or use the allowlist your payment provider documents.
COOP and CORP
Cross-Origin-Opener-Policy: same-origin puts your page in its own browsing context group. MDN warns that this breaks flows that rely on window.opener, such as OAuth sign-in popups and some payment popups, and recommends same-origin-allow-popups for those. That's why the config above uses same-origin-allow-popups as the safer default for business sites.
Cross-Origin-Resource-Policy: same-site stops other sites from loading your resources. Don't set it on assets you deliberately share, such as images used in emails or a public widget script. You only need Cross-Origin-Embedder-Policy: require-corp if you need cross-origin isolation (for example SharedArrayBuffer); most business sites don't, and it can block third-party embeds.
Headers you can leave out
X-XSS-Protection controlled a filter that modern browsers have removed. OWASP advises not setting it, and recommends a CSP instead. Expect-CT is also obsolete.
The add_header inheritance gotcha
This is the most common reason headers vanish. The nginx docs say add_header directives are inherited from the previous level "if and only if there are no add_header directives defined on the current level".
In practice:
server {
include snippets/security-headers.conf; # 8 headers here
location ~* \.(css|js|woff2|webp)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
# This location now sends ONLY Cache-Control.
# All 8 security headers are gone for these files.
}
}
The single add_header in the location wipes out everything inherited from server. The same happens with any location that sets CORS headers or a custom header, which is common in WordPress and Laravel configs.
Fix 1: include the snippet again
The portable fix, which works on every Nginx version, is to repeat the include in any block that has its own add_header:
location ~* \.(css|js|woff2|webp)$ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Fix 2: add_header_inherit merge (nginx 1.29.3 and later)
nginx 1.29.3 introduced add_header_inherit. Setting it to merge appends the headers from the previous level to the ones defined at the current level:
http {
add_header_inherit merge;
}
Check your version with nginx -v before relying on this. Many distributions and hosting panels ship older stable releases, where the directive causes a config error. If you can't confirm the version, use Fix 1.
Watch out for if blocks and duplicates
An add_header inside if in a location behaves like its own level too. And if your application (PHP, Node, a CMS plugin) already sends a header that Nginx also adds, browsers receive it twice. Two different Content-Security-Policy headers are both enforced, which can block more than you expect. Pick one place to set each header.
Hiding version numbers and stack details
server_tokens defaults to on, which puts the nginx version in the Server header and on error pages. Setting it to off hides the version number; the header still says nginx. Removing the header entirely needs the commercial subscription or a third-party module, and isn't worth the effort.
PHP often adds X-Powered-By: PHP/8.x. Hide it at the Nginx level:
location ~ \.php$ {
fastcgi_hide_header X-Powered-By;
# ... fastcgi_pass etc.
}
# For a proxied Node or Python app:
location / {
proxy_hide_header X-Powered-By;
proxy_pass http://127.0.0.1:3000;
}
You can also set expose_php = Off in php.ini. OWASP recommends removing X-Powered-By everywhere.
How to test your Nginx security headers
-
Check the syntax before reloading:
sudo nginx -t && sudo systemctl reload nginx -
Check a normal page:
curl -sI https://example.com/ | grep -iE "strict|content-security|x-content|x-frame|referrer|permissions|cross-origin|server" -
Check an error page. Request a URL that doesn't exist. If the headers are missing here, you've forgotten
always:curl -sI https://example.com/this-page-does-not-exist -
Check static files and special locations. Request a CSS file, an image and any API path. Missing headers here mean the inheritance gotcha.
-
Check from outside. A CDN or load balancer in front of Nginx can add, strip or duplicate headers. Run our security headers checker, which reads the headers your site actually sends for HSTS, CSP, X-Content-Type-Options, X-Frame-Options or frame-ancestors, Referrer-Policy, Permissions-Policy, COOP and CORP, and gives copy-paste fixes for anything missing.
-
Browse the site with the console open for a day after switching CSP from report-only to enforcing, especially checkout, contact forms and embedded maps or videos.
Nginx on managed hosting and Plesk
If you don't have root access, you may still be able to add headers. Plesk has an Additional nginx directives box under Apache & nginx Settings for each domain, which accepts add_header lines. On managed WordPress hosts running Nginx, headers are often set by support on request, or through a CDN such as Cloudflare using response header transform rules. If you use Cloudflare, also see our Cloudflare free plan settings guide, because HSTS can be set there instead of in Nginx; set it in one place only.
For a wider look at what each header protects against, read our HTTP security headers guide, or run a free website audit to check headers, HTTPS and certificate expiry together.
Frequently asked questions
Why are my Nginx security headers missing on some pages?
Usually because a location block has its own add_header, which cancels all inherited headers, or because the response is an error code and you didn't use always. Include your header snippet in every block with its own add_header and add always to each line.
Where should add_header go in Nginx: http, server or location?
Put security headers in the server block for each HTTPS site, via an included snippet. Placing them in http works too, but any server or location with its own add_header will drop them unless you re-include them or use add_header_inherit merge on nginx 1.29.3 or later.
What does the always parameter do in add_header?
It makes Nginx add the header whatever the response status. Without it, headers are only added to specific codes such as 200, 204, 301, 302 and 304, so 404 and 500 pages miss them.
Should I use X-Frame-Options or CSP frame-ancestors?
Use both. Modern browsers follow an enforced frame-ancestors directive and ignore X-Frame-Options, while older browsers rely on X-Frame-Options. If your CSP is still in report-only mode, X-Frame-Options is your only active protection.
Does server_tokens off remove the Server header?
No. It hides the nginx version number on error pages and in the Server header, but the header still reads nginx. Removing it entirely requires the commercial subscription or an extra module.
Sources
Written by the MakeItFlow team
We build the website audit these guides are based on. Every fact is checked against the primary sources listed above and this guide was last reviewed on 25 October 2026 . How we research and check
Check your own site in 30 seconds
Run a free audit covering speed, SEO, DNS, email authentication and security headers — and get a prioritised fix list.