Prevent clickjacking: X-Frame-Options vs CSP frame-ancestors
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:
DENY— nobody can frame this page, not even you.SAMEORIGIN— only pages from your own origin can frame it.
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:
| Intent | Send these two headers |
|---|---|
| Nobody frames it | X-Frame-Options: DENY + frame-ancestors 'none' |
| Only your own site frames it | X-Frame-Options: SAMEORIGIN + frame-ancestors 'self' |
| Specific partners frame it | frame-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
- Set it everywhere, not just the homepage. Framing protection has to cover every sensitive route. I've seen sites with a locked-down homepage and a wide-open
/accountbecause the header was set on one response and not the others. Set it globally at the server or CDN level. - Don't let an over-broad CSP undo it. If you're already sending a Content-Security-Policy, add
frame-ancestorsto it rather than sending a second, conflicting CSP header. - Test after every infra change. A CDN migration, a new reverse proxy, a framework upgrade — any of these can quietly drop a security header. The protection was there in staging and gone in production, and nothing errors.
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