How to check if a site is vulnerable to clickjacking

Serve a local page whose iframe points at the target, open it in Chrome, and look at the frame. A rendered page means the target is framable. A grey placeholder plus a console refusal means it is not. Where no browser is available, read the two framing headers and say so.

Why check this

This is an outcome test, not a header test. Run it on the routes that perform an action: login, payment confirmation, permission change, account deletion. A header audit passes a site whose X-Frame-Options says ALLOW-FROM, which browsers discard, and whose CSP has no frame-ancestors line. The framing test fails it, because the page appears inside the attacker page and the button under the invisible overlay is real.

Prerequisites

const http = require('node:http');
http.createServer((req, res) => {
  const target = new URL(req.url, 'http://localhost:8099').searchParams.get('u');
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  res.end(`<!doctype html><meta charset="utf-8"><title>frame test</title>
<h1>Framing ${target}</h1>
<iframe id="f" src="${target}" width="620" height="320"></iframe>
<script>document.getElementById('f').onload = () => console.log('iframe load event fired');</script>`);
}).listen(8099, () => console.log('harness on http://localhost:8099/?u=URL'));
const http = require('node:http');
const routes = { '/open': {}, '/deny': { 'x-frame-options': 'DENY' } };
http.createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...routes[req.url] });
  res.end('<!doctype html><meta charset="utf-8"><title>target</title><h2>target page</h2>');
}).listen(8100, () => console.log('target on http://localhost:8100/'));

Steps

  1. Step 1.

    Open the harness in Chrome against a page with no framing controls, to see what a vulnerable result looks like.

    http://localhost:8099/?u=https://example.com/
    
    iframe load event fired

    The frame shows the Example Domain page in full, including its heading and link. No refusal was logged. This target is framable by any origin.

  2. Step 2.

    Point the harness at a target that blocks framing through CSP.

    http://localhost:8099/?u=https://www.cloudflare.com/
    
    Framing 'https://www.cloudflare.com/' violates the following Content Security Policy
    directive: "frame-ancestors 'none'". The request has been blocked.
    iframe load event fired

    The frame is an empty grey box with a broken-document icon. The refusal names the directive that produced it.

  3. Step 3.

    Point the harness at a target that blocks framing through the older header, so you can tell the two refusals apart.

    http://localhost:8099/?u=http://localhost:8100/deny
    
    Refused to display 'http://localhost:8100/' in a frame because it set
    'X-Frame-Options' to 'deny'.
    iframe load event fired

    The wording differs from step 2. The first refusal comes from CSP, the second from X-Frame-Options, and knowing which one fired tells you which configuration line to change.

  4. Step 4.

    Read the two framing controls with curl. Use this when the build agent has no browser, and record it as a header read rather than a framing result.

    for u in https://example.com/ https://www.cloudflare.com/ https://developer.mozilla.org/en-US/docs/Web/HTTP; do printf '%s\n' "$u"; curl -sI "$u" | tr -d '\r' | grep -i -E '^x-frame-options:|^content-security-policy:' | tr ';' '\n' | sed 's/^ //' | grep -i -E '^x-frame-options|^frame-ancestors' | sed 's/^/  /'; done
    
    https://example.com/
    https://www.cloudflare.com/
    frame-ancestors 'none'
    x-frame-options: SAMEORIGIN
    https://developer.mozilla.org/en-US/docs/Web/HTTP
    x-frame-options: DENY

    example.com printed nothing under its URL, which matches the framable result from step 1. This agreement is what makes the header read usable as a stand-in, and it is not a guarantee.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The target page renders inside the frame | Framable from any origin | Set frame-ancestors 'none' on the route, then repeat step 1. | | Grey box, console names frame-ancestors | CSP blocked the frame | Confirm the directive lists the origins the product actually embeds from. | | Grey box, console names X-Frame-Options | The older header blocked the frame | Add frame-ancestors as well, so the rule survives a CSP being introduced later. | | Console warns the header is not a recognised directive | The header is invalid and dropped | The page is framable. ALLOW-FROM is the usual cause. | | The frame renders only when the parent is the same origin | SAMEORIGIN or frame-ancestors 'self' | Adequate against cross-site framing. Check whether a second hostname of the app counts as cross-origin. |

Common mistakes

Sign: An automated test asserts that the iframe load event never fires, and it passes on a framable page.Cause: Chrome fires load on the iframe in every case above, including the two blocked ones, because the error placeholder is itself a document. Steps 1 to 3 all print the same load line. The verdict is the console message and the rendered frame, not the event.
Sign: The home page cannot be framed, so the site is signed off as protected.Cause: Framing rules come from response headers, and header rules are scoped per path or per upstream. The route that matters is the one with a button that changes state, which is often served by a different application than the marketing home page.
Sign: The framed page shows a login form instead of the logged-in screen under test.Cause: A cookie marked SameSite=Lax or SameSite=Strict is not sent with a cross-site framed request, so the target loads logged out. That is a partial defence, and it disappears the moment a cookie is set to SameSite=None.
Sign: curl shows a framing header and the page still frames in the browser.Cause: The header read and the framing test answer different questions. An invalid value, a second conflicting header, or a policy delivered by a meta tag all change what the browser does with what curl printed.

What to check next

FAQ

How to test clickjacking?

Run steps 1 to 3 against the route that performs an action, while logged in. A page that renders in the frame with its real controls is the finding. Building an overlay to prove a click can be stolen is a separate exercise and is not needed for the verdict.

How to test if a page can be framed in an iframe?

Serve the harness from an origin that is not the target, load it in Chrome, and read the frame. Opening an HTML file from disk works too, but a file:// parent behaves differently from a real site, so a local HTTP server gives the truer answer.

Can I check this without a browser?

Step 4 reads both framing headers. Report it as a header read. The browser applies rules that headers alone do not show, including which of two conflicting headers wins and whether a value is valid at all.

Does a framed page that loads logged out mean the site is safe?

No. It means the session cookie is SameSite=Lax or stricter. The framing control is still absent, and a later change to SameSite=None for a payment or single sign-on flow restores the exposure.

Which routes should I test?

Every route where a single click changes state: login, password change, payment confirmation, permission grant, subscription cancel. Read-only pages carry a lower cost, so test them after the action routes are covered.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny