Security May 18, 2026 6 min read

Stopping Clickjacking: X-Frame-Options vs CSP frame-ancestors

Learn how the X-Frame-Options header prevents clickjacking attacks, when to use CSP frame-ancestors instead, and how to verify your config works.

Clickjacking is one of those attacks that sounds almost too simple to work: an attacker loads your site inside a transparent iframe on their own page, tricks a logged-in user into clicking what looks like a harmless button, and that click actually fires off a request on your domain — transferring funds, changing a password, granting OAuth scopes, you name it. The X-Frame-Options header was the original browser-level defense against this, and it's still the single most important header for any page that handles authenticated actions.

Here's what actually works in production, what's been deprecated, and how to verify your setup isn't quietly broken.

What clickjacking actually looks like

A clickjacking page is usually a few lines of HTML:

<style>iframe { opacity: 0.01; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }</style>
<button>Click to claim your prize</button>
<iframe src="https://victim-bank.com/transfer?to=attacker&amount=500"></iframe>

The iframe is nearly invisible and positioned over a fake button. The user clicks the visible button, but the click actually lands on the real Transfer control inside the framed page. If the victim is logged in, the request goes through with their cookies.

This works because, by default, browsers happily render almost any page inside an iframe — unless that page explicitly says otherwise.

How X-Frame-Options blocks the attack

The X-Frame-Options response header tells the browser whether your page may be embedded in a <frame>, <iframe>, <embed>, or <object>. It accepts two values that browsers still honor:

  • DENY — the page cannot be framed by anyone, including your own domain.
  • SAMEORIGIN — the page can only be framed by pages on the same origin (scheme + host + port).

A third value, ALLOW-FROM uri, was specified but is no longer supported by Chrome, Edge, or Safari. If you're still sending it, modern browsers ignore it — which means your page is effectively framable. Don't rely on it.

Picking the right value

  1. If your page never needs to be embedded — login pages, admin panels, account settings, payment flows — use DENY.
  2. If you embed your own pages in iframes (a dashboard widget loading a chart page, for example), use SAMEORIGIN.
  3. If you need to allow specific third-party origins to frame you, skip XFO entirely and use CSP frame-ancestors (covered below).

Setting the header on common stacks

Nginx

add_header X-Frame-Options "DENY" always;

The always flag ensures the header is sent on error responses (4xx, 5xx) too — attackers can sometimes exploit framed error pages.

Apache

Header always set X-Frame-Options "SAMEORIGIN"

Express (Node.js)

app.use((req, res, next) => {
  res.setHeader('X-Frame-Options', 'DENY');
  next();
});

Or just use Helmet, which sets sensible defaults including X-Frame-Options: SAMEORIGIN.

Cloudflare / CDN

You can inject the header via Transform Rules > Modify Response Header. This is useful when you can't easily change the origin server, but be careful: if your origin already sets the header, you'll end up with duplicates, which some browsers treat as invalid.

X-Frame-Options vs CSP frame-ancestors

Content Security Policy's frame-ancestors directive is the modern replacement. It's more flexible and is the defined successor in the CSP Level 2 spec.

  • Content-Security-Policy: frame-ancestors 'none' — equivalent to X-Frame-Options: DENY
  • Content-Security-Policy: frame-ancestors 'self' — equivalent to X-Frame-Options: SAMEORIGIN
  • Content-Security-Policy: frame-ancestors 'self' https://partner.example.com — allows specific origins, which XFO can't do

When both headers are present, the spec says frame-ancestors should take precedence — but real-world browser behavior has historically been inconsistent. The safest approach:

  1. Send both headers if your audience includes older browsers.
  2. Send only frame-ancestors if you only support evergreen browsers and need allowlist behavior.
  3. Never send conflicting values (e.g., XFO DENY with frame-ancestors allowing a partner).

Common mistakes that leave you exposed

  • Setting the header only on HTML pages, not on error pages. A framed 404 page can still be useful for clickjacking if it contains interactive elements.
  • Using ALLOW-FROM. It doesn't work in modern browsers. Migrate to frame-ancestors.
  • Setting it at the application layer behind a reverse proxy that strips or overrides headers. Always verify the header that actually reaches the browser.
  • Forgetting subdomains. SAMEORIGIN means exact origin match. app.example.com cannot frame www.example.com under SAMEORIGIN — you'd need frame-ancestors with both hosts listed.
  • Relying on JavaScript framebusting. Snippets like if (top !== self) top.location = self.location can be defeated with sandboxed iframes that block top navigation. The HTTP header is the only reliable control.
  • Duplicate headers. If your app server and your CDN both inject XFO with different values, browsers may ignore both. Pick one layer.

Verifying it actually works

After you deploy, don't trust the config — verify what the browser sees. Three quick checks:

  1. Open DevTools > Network, reload the page, and inspect the response headers for the document request.
  2. Run curl -I https://yoursite.com from the command line and look for X-Frame-Options and any Content-Security-Policy with frame-ancestors.
  3. Use AXOX Hub's HTTP Header Checker to scan from outside your network — this catches cases where a CDN or WAF is modifying headers in transit, which a local curl won't show.

Then try to actually frame your page. Save this to a file and open it locally:

<!DOCTYPE html>
<html><body>
<h1>Framebust test</h1>
<iframe src="https://yoursite.com/login" width="800" height="600"></iframe>
</body></html>

If your headers are correct, the iframe area will be blank and the browser console will show a refusal message like "Refused to display ... because it set 'X-Frame-Options' to 'deny'". If your page actually renders inside the iframe, your header isn't being applied to that route.

Pages that absolutely need this header

If you're auditing a site and need to prioritize, focus on these first:

  • Login, registration, and password reset flows
  • Payment and checkout pages
  • Account settings, email change, 2FA management
  • OAuth consent screens
  • Admin dashboards and internal tools
  • Any page with state-changing POST actions reachable via GET-triggerable forms

For everything else — marketing pages, blog posts, public docs — SAMEORIGIN is a reasonable default that won't break legitimate embedding while still blocking external framing.

Want to check your site's header configuration right now? Run it through the free AXOX Hub HTTP Header Checker to see exactly what your server is sending, including X-Frame-Options, CSP, and the rest of your security header stack.

Try the free tool

Open Tool