What you'll learn
Quick Answer
Clickjacking tricks a user into clicking something on your site while they think they're clicking something else, usually by loading your page in an invisible iframe layered under a decoy button. The fix is telling the browser your page must never be framed, using the CSP frame-ancestors directive, with X-Frame-Options set alongside it for older browsers.
The Trick
An attacker builds a page with an enticing decoy, "Click here to claim your prize", and loads a target site inside an iframe positioned exactly on top of it, but with its opacity set to zero so it's invisible. The decoy button and your site's real "Delete account" or "Transfer funds" button occupy the same pixels on screen.
The user sees only the decoy. When they click it, the click actually lands on the invisible frame underneath, on your real button, in your real page, using the user's own already-authenticated session. From the browser's point of view nothing unusual happened: a logged-in user clicked a button on a page they were logged into. The only thing out of place is what the user thought they were doing.
Why It Works
Two ordinary browser features combine to make this possible. First, any page can load any other page inside an iframe unless the target explicitly forbids it, there is no default restriction. Second, CSS can make that frame fully transparent and position it with pixel precision over a decoy, so the visual layer the user sees has nothing to do with the layer actually receiving the click.
Because the framed page loads with the victim's normal cookies, it's rendered as a fully authenticated session. The attacker never sees the user's credentials and never needs to; they just need one click landing where they've hidden a genuine, already-logged-in action.
<div style="opacity:0; position:absolute; top:80px; left:200px;">
<iframe src="https://bank.example/transfer?amount=5000"></iframe>
</div>
<button style="position:absolute; top:80px; left:200px;">
Click to claim your prize!
</button>
The Two Headers That Stop It
Per MDN, the legacy header X-Frame-Options supports DENY (never allow framing, from any origin) and SAMEORIGIN (allow framing only by pages on the same origin). A third value, ALLOW-FROM, is obsolete: modern browsers ignore the header entirely when they see it, so it should not be relied on.
The modern replacement is the frame-ancestors directive inside a Content-Security-Policy header, which accepts 'none', 'self', or a list of specific origins allowed to frame the page. It is more flexible than X-Frame-Options and does not fall back to default-src, meaning even a policy that locks down everything else with default-src 'none' still permits framing unless frame-ancestors is set explicitly.
Note that SAMEORIGIN only helps if every subdomain and application sharing that origin is equally trustworthy, since any page on the same origin can still frame the target. For a single-purpose site that's rarely an issue; for a shared platform hosting many customers on subdomains, it can leave a gap that frame-ancestors with an explicit list of trusted origins closes more precisely.
Setting Both, Correctly
Because browser support differs, the correct approach is to set both headers together. Browsers that understand frame-ancestors use it and ignore X-Frame-Options; older browsers fall back to X-Frame-Options. I set both on a real response and read back exactly what a browser would receive:
X-Frame-Options header sent: DENY
Content-Security-Policy header sent: frame-ancestors 'none'With these two headers present, a browser loading this response inside any iframe, same-origin or not, refuses to render it. There is no middle ground to misconfigure here: pick 'none' if the page should never be framed, or list exact trusted origins if a specific embed use case genuinely needs it.
Set both headers on every response that renders sensitive state, not only the obvious candidates like login and payment. A settings page, a password-change form, or an admin action confirmation are just as valuable to an attacker looking for one authenticated click to hijack.
SameSite Cookies Are a Second Layer
Framing headers stop the page from being loaded at all, which is the strongest defense. A complementary layer is setting SameSite=Lax or SameSite=Strict on session cookies. With SameSite set, the browser withholds the cookie on requests that originate from a cross-site context, including a cross-site iframe, so even a page that somehow does get framed loses its authenticated session in that context. Treat this as backup, not a replacement: it depends on cookie behavior that varies by browser and request type, while the framing headers give an explicit, unambiguous rule.
SameSite=Strict is the stronger setting, withholding the cookie even when a user follows a legitimate link in from another site, which can break some cross-site login flows. SameSite=Lax, the default in most modern browsers today, is usually the practical choice: it still sends the cookie on ordinary top-level navigation while blocking it in the embedded, cross-site iframe context that clickjacking depends on.
Where Clickjacking Still Shows Up
Login pages, social "like" or "share" buttons that trigger an action with one click, payment and funds-transfer confirmations, account-deletion screens, and OAuth "Allow access" consent pages are the classic targets, anywhere a single click performs a real, consequential action while authenticated. Any page that changes state on a click and doesn't explicitly set framing headers is worth checking.
The technique earned the name "likejacking" around 2010, when attackers layered invisible Facebook Like buttons under game and video overlays to rack up fraudulent likes at scale, before major platforms added framing protections by default. The same mechanics apply just as easily to a browser extension's permission prompt or a two-factor "approve this login" screen, anywhere a single click is treated as informed, deliberate consent.
A quick way to audit your own site: try loading a sensitive page inside a basic <iframe> on a test page you control. If it renders instead of refusing, the headers aren't set correctly yet.
