You ship a banking app, an admin dashboard, or an OAuth consent screen. An attacker hosts a page with your app loaded invisibly in a transparent iframe, perfectly aligned over a fake button that says "Claim your prize." The user clicks what they think is a harmless button. What they actually click is "Confirm Transfer" inside your app — authenticated with their own session cookie. That's clickjacking, and it's been exploited against Twitter, Facebook, and countless internal tools.
Use frame-ancestors in your Content Security Policy header. It's the correct, modern defense. X-Frame-Options still has a role, but only as a legacy fallback — and conflating the two is a common source of half-broken configurations.
How Clickjacking Actually Works
The attacker doesn't steal credentials. They don't inject script. They just embed your page in an iframe, set opacity: 0, position it over their own content, and wait for the user to click something in a predictable location.
<!-- attacker's page -->
<style>
iframe {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0;
pointer-events: auto;
z-index: 999;
}
</style>
<button style="position:absolute; top:200px; left:150px;">Click me for a prize!</button>
<iframe src="https://yourbank.com/transfer?to=attacker&amount=500"></iframe>The victim is logged into your app. The iframe loads with their session. Their click on the "prize button" lands on your "Confirm" button. Done.
This works because browsers allow cross-origin framing by default unless you explicitly forbid it. No header = anyone can frame your page.
X-Frame-Options: The Old Guard
X-Frame-Options (XFO) was introduced by IE8 in 2009. It has three values:
DENY— never allow this page to be framedSAMEORIGIN— allow framing only from the same originALLOW-FROM https://trusted.example.com— allow framing from a specific origin
The problem: ALLOW-FROM is deprecated and ignored by Chrome, Firefox, and Safari. If you're still relying on it to whitelist a partner domain, your clickjacking protection is silently broken in every modern browser. That's the bug that doesn't announce itself.
DENY and SAMEORIGIN still work fine in all browsers, but XFO has a structural weakness: it only accepts one value. You can't whitelist multiple origins. frame-ancestors CSP solves this.
frame-ancestors CSP: The Right Tool
Content-Security-Policy: frame-ancestors controls which origins can embed your page in a frame, iframe, embed, or object. It supersedes X-Frame-Options in browsers that support CSP Level 2 (which is everything except IE11).
Content-Security-Policy: frame-ancestors 'none';
Equivalent to X-Frame-Options: DENY — no framing from anywhere.
Content-Security-Policy: frame-ancestors 'self';
Equivalent to SAMEORIGIN — same origin only.
Content-Security-Policy: frame-ancestors 'self' https://dashboard.partner.com https://embed.trusted.io;
This is the case XFO can't handle — multiple trusted origins in one directive. You can't express this with X-Frame-Options at all.
The Header You Should Actually Send
For most applications — APIs, admin panels, SPAs, login pages — you want:
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Send both. CSP frame-ancestors takes precedence in modern browsers; XFO covers IE11 and other edge cases. The overhead is two HTTP header lines.
If your app legitimately embeds itself (e.g., an onboarding widget that lives inside an iframe on your own domain):
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'
If you need to allow a specific external partner to frame your page:
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com
Drop X-Frame-Options here entirely — there's no equivalent, and sending SAMEORIGIN alongside a CSP frame-ancestors that includes external origins creates confusing behavior in older browsers where XFO wins.
Setting These Headers in Practice
The Gotcha With CDNs and Reverse Proxies
Here's the one thing that bites teams repeatedly: your app sets the header correctly, but your CDN or load balancer strips or overrides it.
Cloudflare, AWS CloudFront, and Nginx reverse proxies can all overwrite security headers depending on how they're configured. After you deploy your header changes, verify them with:
curl -I https://yourapp.com/sensitive-page | grep -i "frame\|content-security"If you see the header from your app in local testing but not in production, your infrastructure layer is stripping it. This is embarrassingly common and has led to real clickjacking vulnerabilities in apps that thought they were protected.
Also: these headers need to be on every page that has interactive elements, not just your login page. An attacker will target whatever is most valuable — transfer confirmations, settings pages, delete account buttons. Don't forget to cover authenticated routes.
Checking Your Coverage Now
Use the Security Headers tool on EncryptCodec to inspect what your production endpoints are actually returning — paste your URL and see exactly which framing headers are present, missing, or misconfigured. Fix what you find before someone else does.
Frequently Asked Questions
Related posts
OWASP Top 10 2025 Explained — With Real Code Examples for Developers
A practical breakdown of the OWASP Top 10 2025 for backend and fullstack developers, with code examples showing what vulnerable and fixed code actually looks like.
Apr 8, 2026 · 12 min readContent Security Policy: A Practical CSP Implementation Guide for Developers
Learn how to implement Content Security Policy headers correctly, avoid common misconfigurations, and stop XSS attacks before they reach your users.
Mar 30, 2026 · 9 min readCORS Explained: The 5 Most Dangerous Misconfigurations and How to Fix Them
Understand how CORS works, why browsers enforce it, and the five most common misconfigurations that expose your API to cross-origin attacks.
Mar 29, 2026 · 7 min read