Skip to content
ClickJack Test

← Guides

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:

  1. In NGINX. One place covers every backend and every response, including NGINX's own error pages. Usually simpler.
  2. 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.com

Look 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/nonexistent

Then 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.

Check your headers

See whether a site sends X-Frame-Options or CSP frame-ancestors, and what browsers will do with them.