TechEarl

Clickjacking (UI Redress Attacks): The Complete 2026 Practitioner Guide

Clickjacking end to end: invisible iframes, like-jacking and payment-jacking, the 2024 double-clickjacking PoC that sidesteps frame-ancestors, and the headers that actually stop the attack.

Ishan Karunaratne⏱️ 17 min readUpdated
Share thisCopied
Clickjacking and UI redress attacks, variants, and the frame-ancestors and X-Frame-Options defences that stop them

Clickjacking is the UI-layer cousin of the injection bugs on the web application security vulnerabilities map. It is catalogued as CWE-1021 and has been a known class since 2008, but it stays interesting because the defences ride on a single response header that teams routinely forget, and because the attack itself keeps evolving: the 2024 double-clickjacking technique sidesteps frame-ancestors entirely and put the whole class back on the table.

This article sits next to the CSRF deep dive under the same security hub. I cover the original UI-redress attack and its variants (like-jacking, cursor-jacking, drag-and-drop), walk the framebusting arms race and why the 2010-era JavaScript defences failed, then turn around and cover the modern stack (CSP frame-ancestors, X-Frame-Options, SameSite cookies, COOP) in equal depth.

In short: what is clickjacking?

Clickjacking (also called UI redress) is what happens when an attacker loads a legitimate application in a transparent iframe on top of an attacker-controlled page, lines up a button in the framed app with a visible decoy on the outer page, and tricks the victim into clicking the decoy. The click lands on the framed app instead, with the victim's cookies and session attached, so the framed app performs a state-changing action on the victim's behalf. The classical targets are social-network like and follow buttons (like-jacking, follow-jacking), payment-confirmation dialogs (payment-jacking), permission grants (camera, microphone, OAuth consent), and admin actions on internal tools. The primary defence is to refuse to be framed at all via the Content-Security-Policy: frame-ancestors directive (or the older X-Frame-Options header). Clickjacking still matters in 2026 because the 2024 double-clickjacking technique by Paulos Yibelo sidesteps frame-ancestors entirely by using window.open and a double-click race, putting the whole class back on the table for applications that thought they were done with it.

What is clickjacking, mechanically?

Clickjacking is an input-targeting confusion bug. The browser correctly delivers the click to the topmost element under the cursor; the confusion is in the user, who sees the attacker's page but clicks on the framed app. The attacker controls three things: the outer page, the iframe's CSS (size, position, opacity), and the timing of when the decoy is shown. The framed app sees a perfectly normal authenticated request from the victim's browser.

The canonical vulnerable code is no code at all. Clickjacking is the default behaviour of the web: any page that does not opt out via a response header can be embedded in an iframe by any other origin. The browser will attach the framed origin's cookies (subject to SameSite, see below), render the page, and route clicks to it.

The minimal attacker page:

html
<!DOCTYPE html>
<html>
<head><style>
    iframe { opacity: 0.0001; position: absolute; top: 0; left: 0;
             width: 100%; height: 100%; z-index: 2; }
    .decoy { position: absolute; top: 40%; left: 45%; z-index: 1; }
</style></head>
<body>
    <button class="decoy">Click to win a free iPhone</button>
    <iframe src="https://victim-app.example.com/delete-account"></iframe>
</body>
</html>

The iframe is rendered on top of the decoy button at near-zero opacity. The victim sees and clicks the "free iPhone" button; the click lands on the framed delete-account confirm button. If the victim is signed into the victim app, the request is authenticated.

That is the entire mechanism. Every variant below is a different answer to the question "what input are we targeting, and how are we hiding the framed UI?".

Variants

Like-jacking, follow-jacking, payment-jacking

The original variant: a transparent iframe over a social network's like, follow, or share button. The decoy is a clickbait headline or a fake captcha. Facebook saw industrial-scale like-jacking campaigns through 2010-2013, with attacker pages cycling thousands of like targets to inflate engagement on spam content. Twitter saw the same pattern on follow and retweet. Payment confirmation is the higher-value sibling: a transparent iframe over a "confirm transfer" or "approve OAuth scope" dialog, with the decoy positioned so the victim thinks they are dismissing a popup.

Cursor-jacking

The attacker uses a custom CSS cursor (an image of a cursor offset from the real cursor position) to make the victim believe they are clicking somewhere other than where they actually are. The iframe is not invisible; it is just positioned where the victim does not expect their click to land. The technique was demonstrated in 2010 against Flash camera/mic permission prompts.

Scroll-jacking

The attacker hijacks the page's scroll position so the visible viewport of the framed app shows the part the attacker wants clicked, while the surrounding decoy convinces the victim they are scrolling through their own content. Common in long-form "scroll to read more" decoys where the framed action sits halfway down the iframe.

Drag-and-drop-jacking

A more exotic variant where the attacker convinces the victim to drag content (a text snippet, a file) from the attacker's page into a framed input. The framed app sees a legitimate drop event populated with attacker-controlled data. This was the basis of some early cross-origin data-theft demonstrations against Gmail's compose window in 2010.

Double-clickjacking (2024)

The newest variant and the most interesting one. Disclosed by Paulos Yibelo in December 2024, double-clickjacking sidesteps frame-ancestors entirely because it does not use an iframe.

The mechanic: the attacker page opens a popup with window.open pointing at the victim's sensitive action page (an OAuth consent, a permission grant, a "confirm delete account" button). At the same time, the attacker page swaps its own content to overlay a decoy button on the underlying real position the victim's cursor is hovering. The attacker invites the victim to double-click anywhere ("click twice to dismiss this captcha"). The first click is consumed by the decoy on the attacker page, which immediately switches focus to the popup and closes the decoy. The second click, on the victim's still-descending mouse, lands inside the popup on the now-focused sensitive button.

There is no iframe. frame-ancestors 'none' does not help because the victim page is not framed; it is in a separate top-level window. X-Frame-Options: DENY does not help for the same reason. The popup attaches the victim's cookies as a normal top-level navigation, which means even SameSite=Lax cookies flow (top-level navigations are Lax-allowed by design). Defences require either a UI confirmation step that breaks the timing window or a client-side check that the document has been the focused window for longer than a single-click interval.

I cover double-clickjacking specifically in the defences section because the standard headers do nothing for it.

The classic exploit, end to end

The minimum reproducer for classical clickjacking. Assume the victim app is https://victim.local and has a self-service /delete-account confirm button on a GET URL (already a bug, but a depressingly common one).

The attacker page (https://attacker.local/clickjack.html):

html
<!DOCTYPE html>
<html>
<head><title>Free iPhone Quiz</title>
<style>
    body { margin: 0; font-family: system-ui; }
    .hero { padding: 4rem; text-align: center; }
    .frame { position: absolute; top: 0; left: 0;
             width: 100%; height: 100vh;
             opacity: 0.05; z-index: 10; }
    .decoy { position: fixed; top: 50%; left: 50%;
             transform: translate(-50%, -50%);
             padding: 1rem 2rem; z-index: 5;
             background: cornflowerblue; color: white;
             font-size: 1.25rem; border: 0; border-radius: 6px; }
</style></head>
<body>
    <div class="hero"><h1>Spin to win!</h1></div>
    <button class="decoy">SPIN</button>
    <iframe class="frame"
            src="https://victim.local/delete-account"></iframe>
</body>
</html>

The iframe is at five-percent opacity so a tester can see the target button while developing the alignment; in a real attack the value is 0.0001 or pointer-events is shifted around. The decoy is centred over the framed confirm button. The victim signs in to victim.local in one tab (or has a fresh session from earlier), then visits attacker.local/clickjack.html from a phishing link or a forum post. They click SPIN. The click lands inside the iframe on the real confirm button. The account is deleted.

The browser does exactly what it is told: cookies for victim.local are attached to the framed request, the user gesture is real, and the framed origin sees an authenticated state-changing action.

The framebusting arms race (why JS defences failed)

Before X-Frame-Options shipped, applications tried to defend themselves with JavaScript that checked whether they had been loaded in a frame and broke out if so. The 2010-era pattern:

html
<script>
    if (top.location !== self.location) {
        top.location = self.location;
    }
</script>

The idea is that if the page is framed, it forces the top window to navigate to itself, escaping the iframe. Every variant of this defence was broken within months:

  • sandbox iframe attribute. An attacker can frame the victim with <iframe sandbox="allow-forms" src="...">. The sandbox attribute, when it does not include allow-top-navigation, forbids the framed page from changing top.location at all. The framebusting assignment silently fails.
  • beforeunload prompt suppression. The framing page registers a beforeunload handler that returns a string, which historically popped a "Are you sure you want to leave?" dialog. The attacker page calls history.go(-1) in a tight loop or uses other navigation tricks to keep the user trapped while the click is delivered.
  • onbeforeunload cancel. Older browsers let the framing page cancel navigations from framed pages; the framebusting code ran, attempted the top-level navigation, and was silently cancelled.
  • 200-deep iframe nesting. Browsers cap recursive framing at around 200 levels. The attacker frames the victim 200 levels deep; the victim's top.location = self.location assignment is silently dropped because the navigation budget is exhausted.
  • <noscript> defeat. If the framebusting script was wrapped in <script> only, the attacker disables JavaScript in the iframe via sandboxing. Many framebusters did not have a CSS fallback that hid the page when JS was off, so the page rendered (clickable) without the busting code ever running.

The lesson from the 2010-2012 framebusting research (Stanford's "Busting Frame Busting" paper is the canonical reference) is that any defence the framed page tries to enforce at runtime can be defeated by the framing page, which controls the parent context. The only working defence is a response header that the browser enforces before the framed page is even loaded. That is X-Frame-Options and its modern replacement, frame-ancestors.

Modern defences

CSP frame-ancestors

The modern, preferred defence is the frame-ancestors directive in a Content-Security-Policy response header. Documented at MDN's frame-ancestors page. It tells the browser which origins are allowed to embed this page in a frame; everything else is refused at the framing step, before any page content runs.

The three useful values:

code
Content-Security-Policy: frame-ancestors 'none';
Content-Security-Policy: frame-ancestors 'self';
Content-Security-Policy: frame-ancestors 'self' https://partner.example.com;
  • 'none' refuses all framing. Equivalent to X-Frame-Options: DENY. Correct default for any sensitive page (admin, payment, account settings, OAuth consent).
  • 'self' allows only same-origin framing. Equivalent to X-Frame-Options: SAMEORIGIN. Correct for pages that are framed inside the application's own shell.
  • 'self' https://partner.example.com allows same-origin plus a named partner, useful for embedded widgets with a known consumer.

frame-ancestors supports multiple origins, which X-Frame-Options: ALLOW-FROM never did interoperably (Chrome never shipped ALLOW-FROM; the directive was de facto deprecated for years before being removed from the spec). If you need an allow-list of more than one origin, you need CSP.

X-Frame-Options as fallback

The older header is X-Frame-Options, shipped by Microsoft in IE8 in 2009 and supported by every browser since. Documented at MDN's X-Frame-Options page. Three values were defined; only two are usable today:

code
X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN
X-Frame-Options: ALLOW-FROM https://partner.example.com

DENY and SAMEORIGIN work everywhere. ALLOW-FROM was never implemented in Chrome or Safari, was removed from Firefox in version 70 (2019), and is now considered deprecated. If your defence depends on ALLOW-FROM, the defence is missing on most of your traffic; switch to CSP frame-ancestors.

When both headers are present, frame-ancestors wins per the CSP Level 2 specification: a browser that supports CSP frame-ancestors must ignore X-Frame-Options if frame-ancestors is also set. In practice, ship both anyway. The marginal cost is two response-header bytes per request; the upside is coverage of any client that supports one but not the other (rare, but cheap insurance).

A correct sensitive-page response sets both:

code
Content-Security-Policy: frame-ancestors 'none';
X-Frame-Options: DENY

Defending against double-clickjacking

The standard headers do nothing for the 2024 double-clickjacking technique because there is no iframe to refuse. The defence has to live in client-side JavaScript on the sensitive page itself, with one of three patterns:

  • Require a meaningful focus duration. Before accepting a click on a sensitive control, check that document.hasFocus() has been true for longer than the minimum double-click interval (around 500ms). Newly-focused windows from window.open fail this check.
  • Defer dangerous buttons until first user interaction. Render the confirm button in a disabled state, then enable it only after the page has seen a real pointermove, keydown, or other independent user gesture that is not part of the focusing click. The popup's first click lands on a disabled button and does nothing.
  • Server-side step-up. For genuinely sensitive actions (payment, account deletion, OAuth consent for a powerful scope), require a re-authentication or a second confirmation that cannot be satisfied by a single click. A WebAuthn touch, a typed confirmation phrase, a code from an authenticator app: any of these break the timing window because the attacker cannot simulate them through a popup-and-click.

The point is that double-clickjacking moved the threat model. Frame-busting headers handle the framed-iframe class; they do not handle the popup-and-race class. Both need separate attention on any high-value action.

SameSite cookies reduce the impact

A cookie with SameSite=Lax (the modern default in Chrome and Firefox) is not sent on cross-site subresource requests. Iframes are subresource loads, so a framed cross-site page does not receive the user's Lax cookies on its embedded requests, which means the framed page rendered for a victim does not have the victim's session unless cookies are also SameSite=None.

code
Set-Cookie: session_id=<value>; HttpOnly; Secure; SameSite=Lax; Path=/

There is an important caveat. The framing-and-cookies interaction depends on which request the cookie is attached to. The framed top-level document request itself is a subresource of the parent page, so cross-site Lax cookies are not sent on it. That means a typical "framed page sees no session" outcome for Lax cookies. Pages that explicitly set SameSite=None for legitimate cross-site embedding (federated login flows, payment processors loaded in an iframe) re-open the classical clickjacking window for themselves, and have to compensate with strict frame-ancestors.

SameSite=Lax does not help against double-clickjacking. The popup is a top-level navigation; top-level cross-site requests carry Lax cookies by design, which is the whole point of the Lax setting. The popup gets the victim's session normally.

UI confirmation for sensitive actions

For the highest-impact actions (payment confirmation, OAuth consent for write scopes, account deletion, password change, MFA disable), do not rely on a single click being intentional even with all the header defences in place. Require a typed confirmation, a re-entered password, a WebAuthn touch, or a code from an authenticator app. The cost in usability is small; the cost of a missed clickjack on these actions is large.

OAuth consent pages are the obvious example. Google, Microsoft, and Auth0 all render their consent screens with frame-ancestors 'none', and the consent grant on high-privilege scopes (drive read, mail send, admin) requires a separate verification step. The double-clickjacking research targeted OAuth flows specifically because the consent click is the entire authorisation; the response was to require a second user gesture before the grant fires.

Cross-origin isolation, COOP, COEP

Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp are not direct clickjacking defences, but they tighten the cross-origin window relationship in ways that matter for the double-clickjacking class. COOP with same-origin severs the window.opener relationship when navigating between cross-origin documents, which prevents some of the popup-orchestration patterns used by double-clickjacking. Set both on sensitive pages regardless; the side-effect on clickjacking is a bonus, not the primary reason.

Real-world incidents

A short tour of clickjacking in production.

  • Adobe Flash Player settings panel (October 2008). The original public demonstration by Robert Hansen and Jeremiah Grossman framed the Adobe Flash settings panel (which lived on a non-framebusted Adobe page) and tricked users into granting any framing page permission to use their webcam and microphone. The incident is the canonical "this is a real attack" moment for the class and the reason Adobe shipped framebusting on the settings page within weeks.
  • Twitter retweet button (February 2009). A clickjacking attack against the Twitter web client posted a self-propagating tweet ("Don't Click") to anyone who clicked a decoy button on a malicious page. Twitter pushed a framebusting fix the same day; the incident is still cited as the trigger for X-Frame-Options adoption being taken seriously across the industry.
  • Facebook like-jacking campaigns (2010-2013). Industrial-scale like-jacking through this period inflated engagement on spam and clickbait pages by routing victims' clicks to the Facebook like button via transparent iframes. Facebook responded with X-Frame-Options on the like-button widget endpoint and a server-side check on the referring origin for like actions originating from frames.
  • DoubleClickjacking PoCs (December 2024 onward). Paulos Yibelo's writeup demonstrated the technique against Salesforce, Slack, and several OAuth flows. Vendors have shipped per-page mitigations (focus-duration checks, deferred-enable patterns) on the highest-impact actions; the broader pattern of single-click sensitive confirmations is still being audited across the industry.

Common defence mistakes I still see

  1. X-Frame-Options: ALLOW-FROM as the only defence. Chrome and Safari never implemented it. Firefox removed it. The header is silently ignored by most of your traffic. Use CSP frame-ancestors instead, and if you need cross-browser coverage for older clients, pair it with X-Frame-Options: DENY or SAMEORIGIN.
  2. JavaScript framebusting only. The Stanford research from 2010 enumerated every bypass for top-level-location framebusters. If your defence is if (top !== self) top.location = self.location; and nothing else, an attacker with a sandboxed iframe defeats it in one line. Use the headers; the JavaScript is at best a complement, not a replacement.
  3. Setting frame-ancestors in a <meta http-equiv> tag. CSP frame-ancestors is one of the directives that is ignored when delivered via a meta tag. It must come from a response header. The browser refuses to enforce frame-ancestors specified in HTML because the document has already been framed by the time HTML parses.
  4. Trusting frame-ancestors 'none' against double-clickjacking. It does not help. The popup is not a frame. Audit every sensitive single-click action for the double-clickjacking pattern separately and add a focus-duration or step-up gate.
  5. Sensitive actions on GET URLs. A self-service action that fires on a plain GET (/delete-account?confirm=1) is clickjackable, CSRF-able, and pre-fetch-vulnerable in one package. Make state-changing actions POSTs with anti-CSRF tokens (see the CSRF deep dive) and require a second confirmation gesture on the high-impact ones.
  6. Forgetting the partner-allowlist case. A payment iframe shipped from a processor needs frame-ancestors set to the customer origins that legitimately embed it; if it ships with 'none', embedding breaks; if it ships with *, every site on the internet can frame it. The allow-list must be explicit and reviewed when partners change.
  7. Assuming SameSite=Lax solves it. Lax reduces the impact on iframe-based clickjacking by withholding cross-site cookies on framed subresource loads. It does nothing for top-level popup-based attacks, and it does nothing for the cases where the application itself sets SameSite=None for legitimate cross-site embedding.

Where to go next

The clickjacking cluster fans out from this hub the same way the XSS and CSRF clusters do. The tools listicle for testing UI-redress defences lives at best clickjacking tools 2026, covering Burp Suite's clickbandit, the OWASP ZAP clickjacking add-on, the manual transparent-iframe harness, and the double-clickjacking PoC code.

For the wider map, back up to the web application security vulnerabilities taxonomy. For the closest analogue at the request layer (an authenticated victim making a state-changing request they did not intend), the cross-site request forgery deep dive is the natural pairing: same mistake at the request layer, same shape of defence at the cookie and origin-check layer.

Sources

Authoritative references this article was fact-checked against.

TagsClickjackingUI RedressSecurityWeb SecurityOWASPCSPframe-ancestors

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts