← Back to home
Security · Attack & defense

Clickjacking,
and how to stop it.

Clickjacking is an iframe trick. An attacker loads your page in a transparent iframe on top of their page, lines up your “Delete account” button with their “Win a prize” button, and waits for the victim to click. The browser believes the click landed on your site; you process it as legitimate; the user has no idea what they actually did. Below: a working PoC, the four defense headers, and how to verify your own site.

Updated:11 May 2026Read:6 minLevel:Intermediate

What clickjacking actually is

Every browser still lets you load most third-party sites in an iframe by default. If you set the iframe's opacity to 0 and stack it on top of your own visible page, you have an invisible interactive surface that looks like your page but is theirs. Every click the user makes lands on the real site below the cursor; the real site sees a legitimate, authenticated click and acts on it.

That is the entire vulnerability. There is no SQL injection, no XSS, no CSRF token to steal. The user is logged in, the click is real, and the action is exactly what the site is designed to do — just not what the user wanted.

A working proof of concept

The full attack page is 20 lines of HTML. Save it locally, open it in a browser, and try it against any test site that does not send framing headers:

evil.html — the attacker's page.
<!DOCTYPE html>
<title>You won a prize!</title>
<style>
  body { margin: 0; }
  .bait {
    position: absolute; top: 200px; left: 200px;
    padding: 20px 40px; font-size: 28px;
    background: #fde68a; cursor: pointer;
  }
  iframe {
    position: absolute; top: 0; left: 0;
    width: 100%; height: 100%;
    opacity: 0.001;          /* invisible but interactive */
    border: 0;
  }
</style>
<div class="bait">Click to claim &rarr;</div>
<iframe src="https://target.example/account/delete"></iframe>

The bait div is at top: 200px left: 200px. The attacker positions the real “Delete account” button at exactly the same spot inside the iframe (by scrolling, framing a specific sub-page, or using iframe-srcdoc). The click goes through the bait, through the invisible iframe, and lands on the real button.

How to check whether your site is vulnerable

Inspect the response headers on any sensitive route — login, account settings, password change, money transfer, anything that takes an authenticated action. Run this on every protected URL, not just the homepage:

curl -sSI https://yoursite.example/account | grep -iE 'x-frame-options|content-security-policy'

If neither line comes back, your site is currently embeddable. Open DevTools, run a test framing attempt:

<iframe src="https://yoursite.example/account"
        style="width:600px;height:400px;border:1px solid red"></iframe>

If the iframe renders the page, you are vulnerable. If the browser refuses with a console message like Refused to display in a frame, your headers are working.

The four defenses, ranked

1. frame-ancestors in Content-Security-Policy (the right answer)

This is the modern, recommended defense. It supersedes X-Frame-Options on every browser that supports CSP Level 2 (essentially all of them since 2015):

Content-Security-Policy: frame-ancestors 'none'

For pages that need to be embeddable by specific partners only:

Content-Security-Policy: frame-ancestors 'self' https://partner.example

2. X-Frame-Options: DENY (the legacy answer)

The older header. Still respected everywhere; cannot do partner allowlists (the third value, ALLOW-FROM, is no longer supported in any browser). Send it alongside CSP for defense in depth:

X-Frame-Options: DENY
# or, allow same-origin embedding:
X-Frame-Options: SAMEORIGIN

3. SameSite cookies (defense in depth, not the main defense)

When the framing happens via a top-level cross-site navigation, SameSite=Lax(the modern default) blocks the victim's session cookie from being sent. That defeats one common attack delivery vector. It does not help when the user is already in a session, so do not rely on it as the only defense.

Set-Cookie: session=...; Secure; HttpOnly; SameSite=Lax

4. JavaScript frame-busting (deprecated, do not rely on)

The old pattern of if (window.top !== window.self) top.location = self.location is trivially defeated by HTML5 sandbox restrictions on top-navigation. It is listed here so you can remove it from your codebase, not so you can add it. See the dedicated guide on checking whether your page is framed for details on why the JS check alone is not a defense.

Setting the headers on your stack

Copy-paste configurations for the common deployment targets:

Nginx

add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;

Apache (.htaccess)

Header always set X-Frame-Options "DENY"
Header always set Content-Security-Policy "frame-ancestors 'none'"

Cloudflare Workers / Pages (_headers file)

/*
  X-Frame-Options: DENY
  Content-Security-Policy: frame-ancestors 'none'

Vercel (vercel.json)

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" }
      ]
    }
  ]
}

Express (Node.js)

import helmet from 'helmet';
app.use(helmet.frameguard({ action: 'deny' }));
app.use(helmet.contentSecurityPolicy({
  directives: { frameAncestors: ["'none'"] },
}));

Common mistakes

  1. Setting headers only on /login. Every authenticated route is a target. Apply the headers globally.
  2. Using SAMEORIGIN when you mean DENY. SAMEORIGIN allows your own domain to frame you — which is fine until a subdomain you forgot about gets compromised. Default to DENY; lift to SAMEORIGIN only if you actively iframe yourself.
  3. Forgetting X-Frame-Options on the auth callback. OAuth and SSO callbacks are routes too. They also need framing protection.
  4. Allowing frame-ancestors * — in practice this is equivalent to having no defense at all. Use specific origins.
  5. Relying on the JS check alone. See defense #4 above.

Audit your existing iframes too

The mirror problem of being framed is framing other people: if your site loads third-party iframes (chat widgets, ads, payment SDKs), each of them is an attack surface from the other direction. The Iframe Detector Chrome extension lists every iframe on the active tab with origin, size, and visibility — a 5-second audit beats reading 50 lines of HTML.

Frequently asked questions

No. It is trivially defeated by sandbox='allow-same-origin allow-forms' on the framing page, by HTML5 sandbox restrictions on top-navigation, and by simply running the attack on an older browser that ignores it. Every modern guidance considers JS frame-busting deprecated. Use the headers instead — they are enforced by the browser before any of your script runs.

Yes, for now. frame-ancestors in Content-Security-Policy supersedes X-Frame-Options on every modern browser and is what you should configure. But X-Frame-Options is still respected by some niche browsers and behaves identically for the simple DENY case, so sending both costs nothing and is universally recommended (OWASP, MDN, Google).

Use Content-Security-Policy: frame-ancestors 'self' https://partner.example. Drop X-Frame-Options entirely in that case — X-Frame-Options only supports DENY or SAMEORIGIN, and a single ALLOW-FROM is no longer respected anywhere. Specify the embed-able origins exhaustively in frame-ancestors and you are done.

The broader academic name for clickjacking — any attack where a hostile page tricks the user into interacting with something other than what they think they are interacting with. Variants include cursorjacking (hidden cursor offset), filejacking (drag-and-drop into a hidden iframe), and likejacking (one-click 'Like' theft on social platforms). All of them share the same root cause and the same defense.

It blunts one specific variant. With SameSite=Lax (the default in modern Chrome), the victim's session cookie is not sent on the embedded request if the framing happens via a top-level navigation. That defeats the simplest demo, but a session that is already established when the user visits the attacker's page still goes through. Use SameSite as defense in depth, never as the only defense.