Clickjacking
UI redress, X-Frame-Options vs frame-ancestors, and why frame-busting JS fails.
Clickjacking
The attacker loads your page in an iframe, makes the iframe transparent, and overlays it precisely on top of an innocent-looking UI like a "Like this kitten" button. The user clicks the kitten, but their click actually lands on the real button in your page — "Authorize $5,000 transfer." The user never realised they interacted with your site at all.
That's clickjacking. The defense is short and well-understood. The reason it's still on every OWASP Top 10? Most teams don't apply it to every sensitive route, just some of them.
The attack mechanic
<!-- attacker.example -->
<style>
iframe {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0.0001; /* invisible */
z-index: 999;
}
.decoy {
position: absolute; top: 200px; left: 300px; /* aligned with the real button */
z-index: 1;
}
</style>
<button class="decoy">Click me to see kittens 🐈</button>
<iframe src="https://bank.example/transfer?to=attacker&amount=5000"></iframe>
When the user clicks the decoy, the click goes to the iframe (highest z-index), which loads the bank's transfer page already pre-filled. If the bank uses cookie-session auth, the request is authenticated. The transfer fires.
Variants:
- Likejacking (Facebook, ~2010): hijacking "Like" clicks to follow attackers, like spam pages, etc.
- Cursorjacking: animation tricks to make the visual cursor differ from the actual click target.
- Filejacking / drag-and-drop variants: attacker tricks user into dragging a file into a hidden iframe upload form.
- Touchjacking (mobile): same idea, with touch events.
The modern defense: CSP frame-ancestors
Content-Security-Policy: frame-ancestors 'none'
That single header tells every modern browser: do not let any other origin frame this page. The iframe attempt fails; clickjacking can't begin.
Variants:
frame-ancestors 'self'— only your own origin can frame the page.frame-ancestors https://partner.example— only the listed partner.frame-ancestors 'none'— nobody. The strongest, the right default.
CSP frame-ancestors is supported by all modern browsers. It supersedes the older X-Frame-Options.
The legacy defense: X-Frame-Options
X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN
X-Frame-Options: ALLOW-FROM https://partner.example ← deprecated, never worked consistently
X-Frame-Options was Microsoft's 2009 invention and shipped before CSP existed. It does almost the same job as frame-ancestors, but:
ALLOW-FROMwas inconsistent across browsers and is effectively dead.- It can't express "allow these N partners."
- Modern specs explicitly mark it as legacy.
Ship both for old-browser compatibility:
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
Modern browsers honor frame-ancestors; older browsers fall back to X-Frame-Options. Belt and suspenders.
Why frame-busting JS doesn't work
The "old defense" — JavaScript at the top of every page that detects framing and breaks out:
if (top !== self) top.location = self.location;
This is broken for several reasons:
- The attacker wraps your iframe in
sandbox="". A sandboxed iframe forbids JavaScript by default. Your frame-busting code never runs. - The 204-response trick. The attacker uses a
beforeunloadhandler that returns a 204 (No Content) response, freezing the navigation attempt. - The sandbox + allow-scripts trick: even if scripts are allowed,
allow-top-navigationis not — yourtop.location = …is a no-op. - Browser bugs and edge cases: a long history of frame-busting bypasses (Stanford's 2010 paper documented dozens).
The only reliable defense is server-side headers that the browser enforces before your JS runs. JavaScript-in-the-page defenses are doomed by construction.
Drag-and-drop variants and other UI redress
Clickjacking is one species of "UI redress" — the broader class of attacks where the user thinks they're interacting with one UI but is actually interacting with another. Other examples:
- Drag-and-drop: attacker tricks user into dragging text from one frame to another, leaking data across origins.
- Touchjacking: mobile equivalent of clickjacking with touch events.
- Cursor-spoofing: CSS shifts the visible cursor away from the actual click point.
The same defense (frame-ancestors) handles all of them, because they all require the attacker to embed your page in their context.
What about non-framing UI redress?
If your sensitive action is on a domain that doesn't allow framing, but the attacker's site still tricks the user into clicking — that's not clickjacking, that's confused-deputy / phishing. Different defenses:
- Require explicit confirmation steps for irreversible actions ("Type 'I want to transfer $5000 to attacker' to confirm").
- Require re-authentication for sensitive flows (re-enter password / hardware key).
- Use the
Sec-Fetch-Siterequest metadata header to detect cross-site initiated requests.
These are good practices regardless of clickjacking.
What to ship
For a typical web app:
On every authenticated route:
Content-Security-Policy: frame-ancestors 'none'; default-src 'self'; …
X-Frame-Options: DENY
On routes that legitimately should be embeddable (a public widget):
Content-Security-Policy: frame-ancestors https://embedder1.com https://embedder2.com
(omit X-Frame-Options — it can't express the multi-origin allow)
On sensitive flows (payments, deletes):
Plus require explicit confirmation tokens / re-auth.
A common framework default is X-Frame-Options DENY everywhere. That's a good starting point. Migrate to CSP frame-ancestors for the granular control and drop the legacy header.
Header-setting in production
Express:
app.use((req, res, next) => {
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'; default-src 'self';");
res.setHeader("X-Frame-Options", "DENY");
next();
});
Django:
# settings.py
MIDDLEWARE = [..., "django.middleware.clickjacking.XFrameOptionsMiddleware"]
X_FRAME_OPTIONS = "DENY"
# plus django-csp for frame-ancestors
CSP_FRAME_ANCESTORS = ("'none'",)
Rails:
config.action_dispatch.default_headers = {
"X-Frame-Options" => "DENY",
"Content-Security-Policy" => "frame-ancestors 'none'; default-src 'self';",
}
Or use Helmet (Node), secure_headers (Rails), django-csp (Django) — they ship sensible defaults that survive code review better than hand-rolled middleware.
Testing it
$ curl -sI https://your.app/sensitive-route | grep -iE "frame-ancestors|x-frame-options"
content-security-policy: frame-ancestors 'none'; ...
x-frame-options: DENY
In CI, fail the build if a sensitive route doesn't emit both headers. Easy to enforce, prevents regression.
A small story
In 2020, a popular CRM allowed framing of its admin console because their CSP was set on the marketing site but not the app. An attacker wrote a "free productivity-app discount" landing page with a hidden iframe of the CRM's "delete all customers" button. They didn't actually delete anyone — they reported it via responsible disclosure. The fix was a one-line header.
Most clickjacking bugs are like that: a missing header, on a route the team didn't think to check. Add the header to the framework defaults, audit your routes once, never think about clickjacking again.
Tools in the wild
3 tools- libraryHelmet (Node)free tier
Sets X-Frame-Options + frame-ancestors out of the box; sensible defaults.
- librarydjango-cspfree tier
Django CSP middleware including frame-ancestors.
- librarysecure-headers (Rails)free tier
GitHub's Rails gem for setting frame-ancestors and other security headers.