Preventing Clickjacking — X-Frame-Options vs CSP frame-ancestors
Clickjacking is one of the few web attacks that needs no vulnerability in your code at all. If your site can be loaded inside an <iframe> on someone else's page, an attacker can trick your logged-in users into clicking your real buttons — deleting data, confirming payments, changing settings — while they believe they're clicking something else entirely. The defence is two response headers, X-Frame-Options and CSP's frame-ancestors, and it takes about five minutes to deploy. Yet framing protection is one of the most commonly missing findings in every header scan we see.
Here's how the attack actually works, which header to use in 2026, and what to do when you legitimately need framing.
A concrete attack walkthrough
Say your app has a settings page at https://app.example.com/settings with a "Delete account" button, protected by a confirmation click. CSRF tokens don't help here — the clicks happen on your real page, tokens and all. The attacker:
- Builds a decoy page — say, a "Win a free gift card" game with a big CLAIM NOW button, hosted anywhere.
- Embeds your page invisibly on top of it:
<iframe src="https://app.example.com/settings"
style="position:absolute; top:-120px; left:-300px;
width:1200px; height:900px;
opacity:0; z-index:10;"></iframe>
With opacity: 0 the frame is invisible but still receives clicks. The top/left offsets are tuned so your Delete account button sits exactly over the decoy's CLAIM NOW button.
- Lures a victim who is logged in to your app. Their browser loads your page inside the frame with their session cookies attached, so it renders fully authenticated.
- The victim clicks CLAIM NOW. The click lands on your real, invisible button. A second decoy click covers the confirmation dialog. The account is gone, and the request looks completely legitimate in your logs — right session, right page, right button.
Variants of this have been used to hijack "like" and "follow" buttons (likejacking), flip camera/microphone permissions in old Flash settings panels, and trigger one-click purchases. Anything a single click (or a short click sequence) can do while logged in is a target.
The fix isn't smarter session handling — it's telling the browser never render this page inside a frame in the first place.
X-Frame-Options: the legacy header that still matters
X-Frame-Options: DENY
Two valid values:
DENY— the page may never be framed, by anyone, including your own site.SAMEORIGIN— only pages from the same origin may frame it.
There was a third, ALLOW-FROM uri — don't use it. It was never supported by Chrome or Safari and is ignored or mishandled by modern browsers. If you need to allow a specific external site to frame you, that's exactly what frame-ancestors is for.
CSP frame-ancestors: the modern replacement
The frame-ancestors directive of Content-Security-Policy supersedes X-Frame-Options — the CSP specification says that when both are present, browsers that support frame-ancestors must ignore X-Frame-Options. It's strictly more capable:
Content-Security-Policy: frame-ancestors 'none'
'none'— equivalent toDENY.'self'— equivalent toSAMEORIGIN.'self' https://partner.example— allow yourself plus specific external origins, the caseALLOW-FROMnever reliably handled. You can list multiple origins and use wildcards likehttps://*.example.com.
Note that frame-ancestors checks the entire ancestor chain — every frame between yours and the top-level page must match the policy, which closes nesting tricks.
Which should you send? Both.
frame-ancestors wins wherever it's supported — which is every modern browser — but X-Frame-Options still covers older engines and costs nothing. The standard belt-and-braces setup:
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Keep the two consistent (DENY with 'none', SAMEORIGIN with 'self') so behaviour doesn't differ by browser. If you already ship a full CSP — and you should; see our practical CSP guide — add frame-ancestors to it rather than sending a second policy header. One thing CSP-savvy readers get wrong: frame-ancestors is not covered by default-src. A policy of default-src 'self' alone leaves you frameable by anyone; the directive must be listed explicitly.
When you legitimately need framing
Some pages are built to be embedded — checkout widgets, booking calendars, support chat, dashboards your customers embed in their intranet. Don't disable protection site-wide for their sake:
- Allowlist, don't open up.
frame-ancestors 'self' https://customer-a.example https://customer-b.exampleon the embeddable route only. - Scope by path. Serve the strict
'none'policy globally and override the header just for/embed/*in your web-server config. - Keep state out of embeddable pages. An embeddable page that can perform one-click authenticated actions is a clickjacking target by design for every origin you allow — keep embeds read-only or require interaction that can't be spoofed by positioning.
If you need any site to embed you (a public widget), frame-ancestors * is honest about that trade-off — but never put a sensitive authenticated action on such a page.
Copy-paste configs
nginx:
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
Apache (requires mod_headers):
Header always set X-Frame-Options "DENY"
Header always set Content-Security-Policy "frame-ancestors 'none'"
Caddy:
header {
X-Frame-Options "DENY"
Content-Security-Policy "frame-ancestors 'none'"
}
If you already send a Content-Security-Policy from these configs or from application middleware, fold frame-ancestors 'none' into that existing policy instead of adding a duplicate header — and remember nginx's add_header inheritance rule: a nested block with its own add_header silently drops the parent's.
Five minutes now, or a very bad incident report later
Framing protection is a fixed, known-good header with essentially no downside for the vast majority of sites — which is why a missing X-Frame-Options/frame-ancestors drags down the grade in any serious header audit, including ours. It sits alongside HSTS, CSP and the rest of the stack we cover in the complete guide to HTTP security headers.
Find out in ten seconds whether your site can be framed: run the free security header checker — it grades you A+ to F and hands you the exact nginx/Apache/Caddy snippet for anything that's missing. Then start monitoring free so a config refactor can never silently remove the header again. The security scans guide explains how the grading works. The free tier allows commercial use.