websec · level 2106

Clickjacking

UI redress, X-Frame-Options vs frame-ancestors, and why frame-busting JS fails.

200 XP

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-FROM was 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:

  1. The attacker wraps your iframe in sandbox="". A sandboxed iframe forbids JavaScript by default. Your frame-busting code never runs.
  2. The 204-response trick. The attacker uses a beforeunload handler that returns a 204 (No Content) response, freezing the navigation attempt.
  3. The sandbox + allow-scripts trick: even if scripts are allowed, allow-top-navigation is not — your top.location = … is a no-op.
  4. 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-Site request 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
  • Helmet (Node)free tier

    Sets X-Frame-Options + frame-ancestors out of the box; sensible defaults.

    library
  • django-cspfree tier

    Django CSP middleware including frame-ancestors.

    library
  • GitHub's Rails gem for setting frame-ancestors and other security headers.

    library