← Back to ClickJack Test

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 (all browsers since ~2018)
add_header Content-Security-Policy "frame-ancestors 'none'" always;

# Fallback — X-Frame-Options (older browser support)
add_header X-Frame-Options "DENY" always;

One header is enough. Both is fine — the browser uses CSP if it understands it, falls back to X-Frame-Options otherwise.

If you embed your own pages in iframes (dashboards, previews), replace 'none' with 'self' and DENY with SAMEORIGIN.

Where to put it

add_header works in http, server, and location blocks.

  • http block — applies to every server on this NGINX instance. Good for global policy. Watch the inheritance trap below.
  • server block — applies to one site/vhost. This is the most common place.
  • location block — applies to a specific URL path. Useful when only certain routes need framing (e.g. 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, 308. Error pages (403, 404, 500, 502, 503) get no header.

This is the single most common NGINX clickjacking misconfiguration.

# 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 requires NGINX 1.7.5 or later (released April 2014). If you are on an older version, upgrade — 1.7.5 has been EOL for over a decade.

The inheritance trap

If you set add_header at the http or server level, any add_header in a child location blockreplaces all parent-level headers. 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 wipes the server-level one.
  }
}

To fix this, repeat the security headers in every location block that uses 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 (March 2025) introduced add_header_inherit merge to change this behavior. If you are on a current version, adding this at the http level makes child blocks append rather than replace:

http {
  add_header_inherit merge;
  add_header X-Frame-Options "DENY" always;
  # ... server and location blocks now inherit this header
}

Reverse proxy mode

If NGINX proxies to a backend (Node.js, PHP-FPM, another server), you have two choices:

  1. Set headers in NGINX. The backend stays unaware of framing policy. NGINX adds the header to every response. This is usually simpler.
  2. Set headers in the backend. The backend is responsible for its own security. Works if every backend sets the header correctly.

If both NGINX and the backend set the same header, the response contains duplicate headers. Browsers handle this safely for XFO and CSP (they apply the strictest policy), but it is sloppy. If you set headers at the NGINX level, use proxy_hide_header to strip the backend's copy:

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;
}

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 directive is obsolete. It was never supported in Chrome or Safari, and modern Firefox versions treat it the same as SAMEORIGIN. Use CSP frame-ancestors https://example.com if you need to allow framing from a specific origin.