NGINX Clickjacking Protection
Setting X-Frame-Options and Content-Security-Policy frame-ancestors in NGINX.
Quick config
Add this to your server block:
# Modern -- CSP frame-ancestors (every major browser since early 2018)
add_header Content-Security-Policy "frame-ancestors 'none'" always;
# Fallback -- X-Frame-Options (Internet Explorer and other old browsers)
add_header X-Frame-Options "DENY" always;Either header alone protects current browsers, and sending both is fine. When an enforced CSP contains frame-ancestors, browsers ignore X-Frame-Options entirely, so keep the two in agreement: a permissive frame-ancestors * overrides DENY.
If you frame your own pages (dashboards, previews), replace 'none' with 'self' and DENY with SAMEORIGIN.
Where to put it
add_header works in http, server, and location blocks (and if inside a location).
- http block: applies to every server on this NGINX instance. Good for a global policy, but see the inheritance trap below.
- server block: applies to one site (virtual host). The most common choice.
- location block: applies to matching URL paths. Useful when one route needs a different policy, such as an embeddable widget.
The always parameter
Without always, add_header only attaches headers to success and redirect responses: 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308. Error responses (403, 404, 500, 502, 503 and so on) go out without them.
This mistake is easy to miss because normal pages look fine; only error pages are left unprotected.
# Wrong -- header missing on 404, 500, etc.
add_header X-Frame-Options "DENY";
# Right -- header on every response, including errors
add_header X-Frame-Options "DENY" always;The always parameter was added in NGINX 1.7.5 (September 2014), so every NGINX version still in use supports it.
The inheritance trap
A block inherits add_header directives from its parent only if it defines none of its own. One add_header in a location (or server) block discards every add_header from the levels above. NGINX does not merge them.
server {
add_header X-Frame-Options "DENY" always;
location /api/ {
add_header Access-Control-Allow-Origin "*";
# X-Frame-Options is now GONE from /api/ responses.
# This location's add_header discards the server-level one.
}
}The classic fix is to repeat the security headers in every block that has its own add_header:
server {
add_header X-Frame-Options "DENY" always;
location /api/ {
add_header X-Frame-Options "DENY" always;
add_header Access-Control-Allow-Origin "*";
}
}NGINX 1.29.3 (October 2025) added the add_header_inherit directive, which every stable release from 1.30 on includes. An add_header_inherit merge; at the http level is itself inherited by every nested block, so child blocks append their headers to the parent's instead of replacing them:
http {
add_header_inherit merge;
add_header X-Frame-Options "DENY" always;
# server and location blocks keep this header even when they add their own
}Reverse proxy mode
If NGINX proxies to a backend (Node.js, PHP-FPM, another server), you can set the headers in either place:
- In NGINX. One place covers every backend and every response, including NGINX's own error pages. Usually simpler.
- In the backend. Works only if every backend sets the header on every response.
If both set the same header, the response carries two copies. That is safe but messy: browsers enforce every CSP header, so the stricter policy wins, and conflicting X-Frame-Options values block framing. To keep one copy, use proxy_hide_header to strip the backend's:
location / {
proxy_pass http://backend:3000;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
}Careful with proxy_hide_header Content-Security-Policy: it removes the backend's whole policy, including script-src and other directives. If the backend sends a full CSP, hide only X-Frame-Options and let NGINX add its own CSP header with just frame-ancestors. Browsers enforce both CSP headers.
Verify it is working
After reloading NGINX, check that the header is present:
curl -I https://yoursite.comLook for X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'none' in the output.
Also test an error page (a URL that returns 404) to confirm always is working:
curl -I https://yoursite.com/nonexistentThen run it through ClickJack Test to confirm.
Notes on ALLOW-FROM
The X-Frame-Options: ALLOW-FROM https://example.com value is obsolete. Chrome and Safari never supported it, Firefox dropped it, and current browsers ignore an X-Frame-Options header that uses it, leaving the page frameable. Use CSP frame-ancestors https://example.com if you need to allow framing from a specific origin.