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
- Node 22 and Chrome. The verdict comes from a browser, so a headless HTTP client cannot produce it.
- curl 8 or later for step 4. See the curl manual.
- The MDN reference on CSP frame-ancestors.
- Save the harness as
frame-test.jsand runnode frame-test.js. Stop it when the check is done.
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'));
- A local target for step 3, which needs a page whose framing header you control. Save it as
target.jsand runnode target.js.
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
- 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 firedThe frame shows the Example Domain page in full, including its heading and link. No refusal was logged. This target is framable by any origin.
- 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 firedThe frame is an empty grey box with a broken-document icon. The refusal names the directive that produced it.
- 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/denyRefused to display 'http://localhost:8100/' in a frame because it set 'X-Frame-Options' to 'deny'. iframe load event firedThe 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. - 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/^/ /'; donehttps://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: DENYexample.comprinted 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
What to check next
- How to check X-Frame-Options: the header behind the step 3 refusal, and the value that browsers discard.
- How to check CSP header: where
frame-ancestorslives, and how to read the rest of the policy. - How to check security headers: the sweep that finds routes serving neither framing control.
- How to check SameSite cookie attribute: whether a framed page carries the session that makes a click worth stealing.
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.
Related on this site
intermediate10 minpublished updated Maks Verny