Security headers for nginx
Add one add_header line per security header, each ending in always, inside the server block that listens on port 443. Any location block with its own add_header drops every header inherited from the server block, so repeat them there or keep them in an included file.
Updated
Check your live site now
Free, no signup, read only. A grade and plain fixes in seconds.
Where nginx security headers go
Put the headers in the server block that handles https (listen 443 ssl). Browsers ignore Strict-Transport-Security on plain http, so there's no point adding it to the port 80 block, which should only redirect.
On Debian and Ubuntu packages the site config usually lives in /etc/nginx/sites-available/ with a link in sites-enabled; on other setups it's often under /etc/nginx/conf.d/. Check which file actually contains your server_name.
The add_header lines
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self' https: data: blob: 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;The always parameter matters. Without it, nginx only adds the header to successful and redirect responses (200, 201, 204, 206, 301, 302, 303, 304, 307 and 308). Error pages would go out without CSP or frame protection, and error pages are often where reflected input shows up.
The CSP here is a permissive starter that won't break a typical site. It still allows inline scripts, so header checkers flag it as weak until you tighten script-src, ideally with nonces or hashes generated by the app behind nginx.
What each header in the nginx block does
- Strict-Transport-Security max-age=31536000; includeSubDomains: browsers use https only, for one year, on this domain and every subdomain. Drop includeSubDomains if any subdomain still has to work over plain http.
- Content-Security-Policy: limits where scripts, styles, frames and form posts can go. The starter value mainly blocks plugins, base tag tricks and framing by other sites.
- X-Frame-Options SAMEORIGIN: only your own pages can put this site in an iframe. Use DENY if you never frame your own pages.
- X-Content-Type-Options nosniff: the browser trusts the Content-Type you send instead of guessing, so check that nginx's mime.types covers the files you serve.
- Referrer-Policy strict-origin-when-cross-origin: other sites see only your domain when a visitor follows a link, not the full path and query string.
- Permissions-Policy camera=(), microphone=(), geolocation=(): the empty list turns each feature off for the page and anything it embeds. Remove a feature from the list if your site actually uses it.
Why add_header disappears in location blocks
nginx inherits add_header from the outer block only if the inner block has no add_header of its own. As soon as a location adds one header, for example Cache-Control for static assets, all the security headers from the server block stop being sent for that location:
server {
listen 443 ssl;
add_header X-Frame-Options "SAMEORIGIN" always;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
# X-Frame-Options is no longer sent for /assets/
}
}The clean fix is to keep the security headers in their own file and include it at the server level and in every location that has its own add_header:
# /etc/nginx/snippets/security-headers.conf holds the add_header lines
server {
listen 443 ssl;
include snippets/security-headers.conf;
location /assets/ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}HTTPS redirect and version headers on nginx
server {
listen 80;
server_name your-site.com;
return 301 https://$host$request_uri;
}
# inside the https server block
server_tokens off;
proxy_hide_header X-Powered-By;server_tokens off removes the nginx version number from the Server header and error pages. When nginx proxies to an app, the app's own headers pass straight through; proxy_hide_header drops the ones you don't want, such as X-Powered-By from Express or PHP. If the app also sets a security header that nginx adds, visitors get two copies, so either hide the app's copy with proxy_hide_header or stop setting it in the app.
Testing and reloading nginx
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://your-site.com | grep -i -E 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy|server'
curl -sI https://your-site.com/this-page-does-not-exist | grep -i x-framenginx -t catches syntax errors before the reload. The last command checks a 404 page: if X-Frame-Options shows up there, the always flag is working. Also check one URL under each location block that sets its own headers, since that's where the inheritance trap bites.
Questions
Why is my nginx add_header not working?
The two usual causes are a location block with its own add_header, which cancels the inherited ones, and a missing always flag, which leaves error responses without the header. Also make sure you reloaded nginx after editing.
Should HSTS go in the port 80 server block?
No. Browsers only honour Strict-Transport-Security when it arrives over https. The port 80 block should just return a 301 to https.
How do I stop nginx showing its version?
Add server_tokens off in the http or server block and reload. The Server header will say nginx without a version number.
Should security headers be set in nginx or in the app behind it?
Fixed headers such as HSTS, nosniff and X-Frame-Options are easy to own in nginx, since it sees every response. A CSP with nonces has to come from the app, which knows which scripts it rendered. Whichever layer owns a header, remove it from the other.