Clickjacking is an attack that loads your site inside an invisible iframe on a page the attacker controls, positioned so the victim's clicks land on your buttons while they believe they are clicking something else. Prevent it by sending Content-Security-Policy: frame-ancestors 'none' and, for older clients, X-Frame-Options: DENY. If a page must be embedded, name the allowed origins instead of dropping the protection.

How the attack works

The attacker builds a page with some bait: a "claim your prize" button, a video play control, a fake CAPTCHA. Behind the bait, in an <iframe> with opacity: 0 and a fixed size and position, is your site, scrolled so that a specific button sits exactly under the bait. When the victim clicks, the browser delivers the click to the topmost element at that point, which is your button inside the frame. Because the victim is logged in to your site, the frame loaded with their session, and the click performs a real action as them: confirming a purchase, changing an email address, granting an OAuth permission, deleting an account, enabling a webcam in an old plugin settings panel.

A concrete version: an attacker wants victims to authorise a malicious OAuth application against a popular service. The service's consent page has an "Allow" button at a fixed position. The attacker's page shows a game with a "Play" button drawn at exactly that position, loads the consent page with the application's client ID in a transparent frame on top, and lets the victim press "Play". The service records a legitimate consent from a logged-in user. No credentials were phished, no code was exploited, and the service's logs show nothing unusual.

The technique is old and simple, first widely discussed in 2008, and it needs no vulnerability in your code. Your site did exactly what it was designed to do. What went wrong is that the browser let another origin place your page where the user could not see it. That is a framing policy problem, which is why the fix is a header rather than a code change.

VariantMechanismTypical target
Classic overlayTransparent iframe over bait contentOne-click actions: confirm, follow, like, approve
CursorjackingDisplaces or hides the cursor so the real click lands elsewherePermission prompts and dialogs
Drag-and-drop / filljackingTricks the user into dragging text into a framed form or triggers autofillStealing autofilled data, injecting content
Likejacking / socialFramed social button under a bait elementFake engagement and spam propagation
Multi-stepRepositions the frame between clicks to complete a several-step flowSettings changes that need a confirmation

The two headers that stop it

Content-Security-Policy: frame-ancestors

The frame-ancestors directive, part of CSP Level 2, tells the browser which origins may embed the page in a frame, iframe, object or embed. It is the modern control: it supports multiple origins, wildcards for subdomains and scheme restrictions, and it is checked against every ancestor in a nested chain, not just the immediate parent. When both this directive and X-Frame-Options are present, the specification says frame-ancestors wins.

# no framing at all: the right default for almost every page
Content-Security-Policy: frame-ancestors 'none'

# only pages on the same origin may frame this one
Content-Security-Policy: frame-ancestors 'self'

# an embeddable widget: list exactly who may embed it
Content-Security-Policy: frame-ancestors 'self' https://partner.example https://*.trusted.example

frame-ancestors can only be set in an HTTP header; it is ignored inside a <meta http-equiv> tag. It can live in the same Content-Security-Policy header as your script-src and other directives, separated by semicolons, and it is not affected by default-src, so a policy that omits it provides no framing protection at all. See Content Security Policy: A Practical Guide for building the rest of the policy around it.

X-Frame-Options

X-Frame-Options (RFC 7034) predates CSP and is still worth sending because it costs one line and covers any client that does not implement CSP Level 2. It has two usable values: DENY and SAMEORIGIN. The third, ALLOW-FROM uri, was never supported consistently and is now ignored by every major browser, so it should not appear in your configuration; use frame-ancestors for allow-lists.

X-Frame-Options: DENY

Send both headers. There is no conflict: modern browsers use frame-ancestors and ignore X-Frame-Options when both are present; older ones use the one they understand.

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

# Apache
Header always set Content-Security-Policy "frame-ancestors 'none'"
Header always set X-Frame-Options "DENY"
The always keyword in nginx and Apache makes the header appear on error responses too. Without it, a 404 or 500 page can be framed, and error pages sometimes contain logged-in navigation with actionable links.

What about pages that are not sensitive?

It is tempting to protect only the account and checkout pages. Protect everything. A blog post is harmless to frame until a comment form or a "subscribe" button on it becomes the target; an error page is harmless until it inherits the logged-in header with a "log out" or "delete draft" link. Sites also change, and a route that was static last year has a button this year. Setting the header globally at the web server or CDN, and relaxing it only on the specific routes that need embedding, is both more secure and far easier to maintain than a per-page decision that someone has to remember.

Why frame-busting JavaScript is not enough

Before the headers existed, sites defended themselves with a script: if (top !== self) top.location = self.location;. It is still found in old templates and still recommended in old tutorials. It does not work. The attacker adds sandbox="allow-forms allow-scripts" to the iframe, which blocks top-level navigation from inside the frame, and the script fails silently. Other bypasses use the onbeforeunload handler, double framing, or simply disabling JavaScript in the frame. OWASP's "legacy" defence, which hides the body with CSS until the script confirms it is not framed, is more robust but still a workaround. The headers are enforced by the browser before any script runs and cannot be sandboxed away. Use them, and delete the frame-busting code.

What SameSite cookies change

Clickjacking only pays off if the framed page is loaded with the victim's session. A cookie with SameSite=Lax or Strict is not sent when the page is loaded inside a cross-site iframe, so the frame shows the logged-out version and the hijacked click hits a login form instead of a confirm button. This is a genuine mitigation and one more reason to set the flags described in Cookie Security Flags. It is not a replacement for the framing headers: same-site framing from a compromised or user-controlled subdomain still carries the cookie, some sessions are held in SameSite=None cookies for legitimate embedding reasons, and the attack can still target actions that do not require login, such as leaving a review, casting a vote or submitting a contact form.

When framing is legitimate

Some pages are meant to be embedded: a checkout widget, a video player, a comment box, a support chat, a "book a slot" panel dropped into partner sites. The wrong response is to leave those pages, or worse the whole site, without a framing policy. The right response is to be specific in three ways.

  1. Separate the embeddable route. Serve the widget from its own path or subdomain so the policy applies only there and the rest of the site keeps 'none'.
  2. Allow-list the embedders. frame-ancestors https://partner-a.example https://partner-b.example. If the list of embedders is genuinely unbounded, ask whether the embedded page performs any state-changing action; if it does not, framing it is harmless.
  3. Make state changes need a second signal. Anything in an embeddable page that changes data should require an explicit gesture the frame cannot forge: a typed confirmation, a re-authentication, or a click that opens a top-level window where the headers apply again.

OAuth consent screens, payment confirmation pages and anything that grants a permission deserve special mention: they are the highest-value clickjacking targets on the web, they are frequently designed to be shown in popups rather than frames, and there is almost never a reason for another origin to embed them. Give them 'none' even if the rest of the application allows some framing.

A page that is embedded by your own site only, such as a preview pane in an admin panel, should use 'self'. Note that 'self' means the same origin including scheme and port, so an http:// parent cannot frame an https:// page under it, which is correct.

Testing and what a scanner reports

The quickest manual test is a local HTML file containing <iframe src="https://example.com/account"></iframe>. If the frame renders, the page is frameable; if it shows a blank area and the console reports a refusal, the policy works. Test a logged-in page as well as the home page, and test an error page.

The Scan.now security headers checker and website scanner read the response headers and report the result under Clickjacking protection: missing entirely, present with an obsolete value such as ALLOW-FROM, a frame-ancestors directive present only in a meta tag, or correctly configured. It is a passive read of a normal response; nothing is framed or clicked. The default severity is medium, and we would raise it for any site with logged-in actions reachable by a single click. On the client side, the browser check reports under Browser enforces X-Frame-Options whether your own browser honours the header at all, which matters because a browser that ignores it leaves you exposed on every site regardless of their configuration.

Our position: frame-ancestors 'none' plus X-Frame-Options: DENY on every response by default, with a narrow, explicit allow-list on the specific routes that need embedding. This belongs in the same server block as the rest of the headers described in HTTP Security Headers Explained, and it is one of the cheapest fixes in the website security hub: two lines, no code change, no user-visible effect, and an entire attack class removed.