Security headers are easy to overlook. They’re not flashy — users never see them — but they add meaningful protection against a range of attacks: clickjacking, XSS, MIME sniffing, cross-origin data leaks. Adding them takes maybe 15 minutes, and scanners like securityheaders.com will grade your site.
Here’s what I actually set and why each one matters.
Strict-Transport-Security (HSTS)
Tells browsers to only connect over HTTPS for a specified period. Once a browser receives this header, it will never make a plaintext HTTP connection to your domain for that duration.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
max-age=31536000 — one year in seconds. Browsers cache this.includeSubDomains — applies to all subdomains too. Only add this if you’re confident all subdomains serve HTTPS.preload — if you add this, you can submit your domain to the HSTS preload list, which gets baked into browsers directly. Permanent — be sure before enabling.
The always parameter in Nginx makes the header appear on error responses too, not just 2xx responses. Usually what you want.
Don’t set this on development servers or on sites where you might switch back to HTTP. The browser will honor it for the full max-age period and won’t let you access the site over HTTP until it expires.
X-Frame-Options
Prevents your pages from being embedded in iframes on other sites. This stops clickjacking attacks.
add_header X-Frame-Options "SAMEORIGIN" always;
SAMEORIGIN — allows embedding by pages on the same origin (same scheme + hostname + port).DENY — no embedding anywhere.
If you have a legitimate use case for cross-origin embedding (a chat widget, embedded dashboard), this will break it. In that case, use Content-Security-Policy: frame-ancestors instead (more flexible).
X-Content-Type-Options
Stops browsers from guessing the MIME type of a response and executing it as something different.
add_header X-Content-Type-Options "nosniff" always;
Without this, a browser might look at a JavaScript file, notice it looks like HTML, and render it as HTML. This opens up attacks where uploaded content with a wrong content type gets executed.
nosniff is the only valid value. Always set this.
Referrer-Policy
Controls how much information is sent in the Referer header when users click links or browsers make requests.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Options:
no-referrer— never send referrer infono-referrer-when-downgrade— send full URL when navigating same-origin or to HTTPS, nothing when navigating to HTTPstrict-origin— only send the origin (not the full URL) in cross-origin requestsstrict-origin-when-cross-origin— send full URL for same-origin, just origin for cross-origin — this is the Chrome default and a good balance
I use strict-origin-when-cross-origin for most sites. It prevents leaking full URLs (including query params with tokens) in referrer headers to third-party sites, while keeping useful analytics data for same-origin navigation.
Permissions-Policy
Controls which browser features the page can use: camera, microphone, geolocation, etc.
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
The empty parentheses () disable the feature entirely. If your app needs geolocation, use geolocation=(self) to allow it from your own origin.
This used to be called Feature-Policy. The rename happened and some older browsers use the old name — you might see both in older configurations.
Content-Security-Policy
CSP is the most powerful security header and also the most complex. It controls which resources the browser can load: scripts, styles, images, fonts, frames, and more.
A restrictive CSP can largely prevent XSS attacks even if an attacker injects HTML. But getting CSP right is tricky — too strict and your site breaks, too loose and it doesn’t help much.
For a site with no inline scripts and no external CDN:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'" always;
For a site using Google Analytics and some external fonts:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https://www.google-analytics.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://www.google-analytics.com; frame-ancestors 'none'" always;
frame-ancestors 'none' is equivalent to X-Frame-Options: DENY — worth setting via CSP since it’s more modern.
Developing a CSP
Start with report-only mode so you can see violations without breaking anything:
add_header Content-Security-Policy-Report-Only "default-src 'self'" always;
Violations get logged to the browser console. Review them, add the necessary exceptions, iterate until you have no violations, then switch to enforcement mode (Content-Security-Policy).
I’ve found CSP takes the most time to get right — every site is different. Libraries, analytics, chat widgets, payment processors all have different requirements.
Cross-Origin Headers
Three newer headers for cross-origin isolation:
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
COOP same-origin — prevents cross-origin windows from accessing your window object.COEP require-corp — only loads cross-origin resources that explicitly opt in.CORP same-origin — prevents other origins from loading your resources.
These are important for enabling advanced features like SharedArrayBuffer and high-precision timers (needed for some WebAssembly code and audio worklets). They also mitigate Spectre-style attacks.
Only set COEP and CORP if all your third-party resources have the appropriate headers. If you embed content from CDNs that don’t set cross-origin headers, COEP will block them.
Putting It Together
Here’s my standard snippet that goes in the http {} block or in a shared config file:
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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;
add_header X-XSS-Protection "0" always;
Note X-XSS-Protection "0" — this disables the old browser XSS filter. Modern security guidance recommends disabling it because the filter itself had vulnerabilities and CSP is the right tool for XSS prevention now.
I include this file in every server block:
include /etc/nginx/conf.d/security-headers.conf;
Testing
Check your headers with curl:
curl -I https://example.com
Or use online tools:
- securityheaders.com — grades your headers and explains what’s missing
- observatory.mozilla.org — Mozilla’s security scanner, checks headers and TLS config
Getting an A or A+ on securityheaders.com is achievable in an afternoon. The CSP is usually the only tricky part.
For the TLS configuration side of things, the TLS 1.3 guide explains the protocol-level security improvements that complement these header-level protections.