Prevent clickjacking: X-Frame-Options vs CSP frame-ancestors

Published October 21, 2026 · about a 7-minute read

Clickjacking is one of those attacks that sounds exotic and turns out to be embarrassingly simple. An attacker loads your site inside an invisible frame on their page, floats their own fake button right over your real one, and your logged-in user clicks "claim your prize" while actually clicking "transfer funds" on your site. The fix is two HTTP headers. The catch is that almost everyone sets up the old one and skips the one that actually matters now. Let me sort it out.

How the attack works

The mechanics are plain. The attacker builds a page with an <iframe> pointing at your site, sets that frame's opacity near zero so it's invisible, and positions an enticing decoy underneath. Because the frame carries your user's existing session cookies, any click lands on your real, authenticated page. The user sees "Play now"; the browser registers a click on "Delete account."

It only works if a browser is willing to render your site inside someone else's frame. So the entire defense is one instruction: tell browsers who is allowed to frame you. Done right, the attacker's invisible iframe simply refuses to load, and the attack collapses.

The old header: X-Frame-Options

X-Frame-Options (XFO) has been the standard answer for years. It takes two values worth using:

X-Frame-Options: DENY

There used to be a third value, ALLOW-FROM uri, meant to permit one specific external site to frame you. Don't use it. It's deprecated, it only ever accepted a single origin, and modern browsers ignore it outright — so a policy built on ALLOW-FROM is really no policy at all. I still find it in the wild, giving teams a false sense of protection. If you need to allow specific external origins, XFO can't do it, full stop.

The modern header: CSP frame-ancestors

The current, more capable control is the frame-ancestors directive inside a Content-Security-Policy header. It does everything XFO does and the thing XFO can't — allow a list of origins:

Content-Security-Policy: frame-ancestors 'none';
Content-Security-Policy: frame-ancestors 'self';
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com https://app.vendor.com;

Map it to the XFO values: 'none' is like DENY, 'self' is like SAMEORIGIN, and a list of origins is the thing you always wanted and never had. This is the authoritative header now.

The precedence rule is the part people miss: when a browser supports frame-ancestors, it enforces that and ignores X-Frame-Options entirely. XFO only comes into play on older browsers that don't understand CSP framing. So if your two headers disagree, modern browsers follow frame-ancestors — make sure it's the one that's correct.

So which do I send? Both.

Here's the setup I actually ship. Send both headers, with matching policies, so modern browsers get the precise frame-ancestors rule and older ones still fall back to XFO:

IntentSend these two headers
Nobody frames itX-Frame-Options: DENY + frame-ancestors 'none'
Only your own site frames itX-Frame-Options: SAMEORIGIN + frame-ancestors 'self'
Specific partners frame itframe-ancestors 'self' https://partner.example.com (XFO can't express this — set SAMEORIGIN as a weak fallback or omit it)

For your most sensitive pages — login, checkout, account settings, anything that changes money or permissions — default to the strictest option (DENY / 'none') unless you have a concrete reason to allow framing. There's rarely a good reason to let your login page be embedded anywhere.

A couple of real-world gotchas

That last point is the theme across every security header: they're invisible when present and invisible when missing. The only way to know your clickjacking defense is actually live is to check the response headers from outside, on the real site, and keep checking.

Check your clickjacking protection

Perimeter inspects your X-Frame-Options and Content-Security-Policy headers — including whether frame-ancestors is set and consistent — alongside your TLS, DNS and accessibility posture. And it re-checks on a schedule, so a header a CDN change silently drops gets flagged before an attacker notices it's gone. Free, no signup.

Monitor your headers → · Agency plan $29/mo, up to 25 domains