Clickjacking: how to test your site and fix it with headers
Clickjacking (also called UI redress or clickjack) tricks a user into clicking a hidden page that an attacker has loaded in a transparent iframe over a decoy. The fix is a response header: Content-Security-Policy frame-ancestors 'none' or 'self', with X-Frame-Options DENY or SAMEORIGIN as a fallback. It is a real finding only when a sensitive action works with one click.
Clickjacking, also called a clickjack attack or UI redress, is an attack where a malicious page loads your page in a transparent iframe on top of a decoy, and the user clicks your page without knowing it. The click is real, it comes from a real session, and it can trigger any action the framed page offers. The fix is a response header that tells the browser which sites may frame your page. This page shows how the attack works, a 5-minute test you can run, header config for eight stacks, and the rule for when a missing header is a real finding.
CWE-1021 (Improper Restriction of Rendered UI Layers or Frames) is the weakness entry for this class.
How does a clickjacking attack work?#
The attacker's page frames the target page, makes the frame invisible with CSS, and places a decoy button under the spot where the target's button sits. The victim clicks the decoy and the browser delivers the click to the invisible frame. This is a "clickjacking attack" in the sense OWASP and PortSwigger describe.
Illustrative test page: Harbor Notes
This is a demonstration. No real customer system was tested. Harbor Notes is a fictional note app and example.com is a reserved example domain.
Harbor Notes has a page https://app.example.com/settings/delete-account with one button, "Delete my account", that works without a second confirmation. A tester with written authorization builds a one-file test page on a separate origin they control:
<iframe src="https://app.example.com/settings/delete-account"
width="600" height="300"></iframe>
If the page loads inside the frame, the site allows framing. The tester then reads the browser console and the response headers. If the console reports that the frame was refused, the control works. In a real attack the iframe would be made transparent and a decoy would sit under the button. The tester does not build that part. Loading the page in the frame is enough to prove the missing control, and a screenshot shows whether the sensitive action is reachable.
Because the victim's browser sends its own cookies to the framed page, the attacker does not need the victim's password or token. A CSRF token does not stop this attack, because the framed page is your own page and it includes a valid token.
What are the main types of clickjacking?#
The variants differ in what the attacker hides and which defense stops it. This table shows what headers stop and what they miss.
| Variant | What happens | Stopped by frame-ancestors or X-Frame-Options |
|---|---|---|
| Classic UI redress | Transparent iframe over a decoy button | Yes |
| Pre-filled form or URL parameter | Frame loads with a form already filled in through the URL | Yes |
| DoubleClickjacking | A separate window swaps in between two clicks | No. OWASP says framing restrictions do not prevent it |
OWASP's Clickjacking Defense Cheat Sheet states that DoubleClickjacking uses separate windows, so frame restrictions do not prevent it. For high-risk actions, it recommends server-enforced transaction authorization. It also says an extra confirmation button alone is not a complete defense against UI redress.
Which real cases are documented?#
The best documented case is the Twitter "Don't Click" worm of 12 February 2009. A tweet read "Don't Click" with a link. The linked page showed a button labeled "Don't Click" with the real Twitter page loaded in an iframe over it, pre-filled with the same message through Twitter's ?status= parameter. A click on the decoy was a click on the invisible Update button. Chris Shiflett's write-up that day describes the mechanism, and Twitter's own blog confirmed it had shipped a change to block the technique. Twitter said the harm was limited to repeated posting of the link. The fix that day was a frame-busting script, which is the weaker kind of fix (see the next sections).
We cite no CVE for clickjacking, because most cases are a missing control on one site, not a flaw in a shared product. If a vendor ships a framing defect in a library, the NVD entry names it, and you should check that entry before you quote it.
How do you fix clickjacking?#
Send Content-Security-Policy: frame-ancestors on every HTML response, and X-Frame-Options as a fallback. OWASP lists these as the primary defenses.
| Control | Browser support | Stops | Fails when |
|---|---|---|---|
frame-ancestors 'none' |
Current browsers | All framing of the page | Sent as a meta tag (ignored), or stripped by a proxy |
frame-ancestors 'self' |
Current browsers | Framing by other origins | A same-origin page you do not control can frame you |
X-Frame-Options: DENY |
Wide, since 2015 per MDN | All framing | A browser that follows frame-ancestors ignores it. A proxy can strip it |
X-Frame-Options: SAMEORIGIN |
Wide | Framing by other origins | Same as above |
X-Frame-Options: ALLOW-FROM |
None in modern browsers | Nothing | Always. MDN says modern browsers ignore the whole header |
| SameSite=Lax or Strict cookie | Most browsers | Logged-in actions in cross-site frames | The action needs no login, or the attacker is same-site |
| JavaScript frame buster | Any | Basic cases | Double framing, onBeforeUnload, sandbox and 204 tricks defeat it |
Use frame-ancestors 'none' unless a page must be framed. OWASP says that setting "is recommended unless a specific need has been identified for framing." The quotes around none and self are required. ALLOW-FROM is obsolete, and OWASP warns that if a browser does not support it, "you will have NO clickjacking defense in place." If you need to allow one partner site, use frame-ancestors https://partner.example.com. A wildcard such as https://*.example.com matches subdomains but not the bare host.
Meta tags do not work for either header. OWASP and MDN both say so.
How do you set the headers in each stack?#
Set the headers in the layer that touches every response. These snippets come from the cited documentation unless stated.
nginx. The always parameter adds the header to error pages too.
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"
IIS (web.config).
<httpProtocol>
<customHeaders>
<add name="Content-Security-Policy" value="frame-ancestors 'none'" />
<add name="X-Frame-Options" value="DENY" />
</customHeaders>
</httpProtocol>
Node.js with Express and Helmet. Helmet sends X-Frame-Options: SAMEORIGIN and a default policy with frame-ancestors 'self'. To refuse all framing:
app.use(
helmet({
contentSecurityPolicy: { directives: { frameAncestors: ["'none'"] } },
xFrameOptions: { action: "deny" },
}),
);
Python and Django. XFrameOptionsMiddleware is on in the default project settings and sends DENY unless you set X_FRAME_OPTIONS. Django 6.0 can also send the CSP header:
from django.utils.csp import CSP
X_FRAME_OPTIONS = "DENY"
SECURE_CSP = {"frame-ancestors": [CSP.NONE]}
The xframe_options_exempt decorator turns the header off for one view. Search the code for it, because each use is a page that can be framed.
Ruby on Rails. Rails sends X-Frame-Options: SAMEORIGIN by default. Change it in config/application.rb:
config.action_dispatch.default_headers["X-Frame-Options"] = "DENY"
Add policy.frame_ancestors :none in the CSP initializer to send the modern header too.
Java and Spring Security. Spring Security adds X-Frame-Options by default and does not add a CSP. Set both:
http.headers((headers) -> headers
.frameOptions((frame) -> frame.deny())
.contentSecurityPolicy((csp) -> csp.policyDirectives("frame-ancestors 'none'"))
);
PHP and Laravel. Add a middleware. This snippet is ours, not from Laravel documentation:
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('Content-Security-Policy', "frame-ancestors 'none'");
$response->headers->set('X-Frame-Options', 'DENY');
return $response;
}
If you send a full CSP, add frame-ancestors to that policy. A second CSP header does not replace the first. The browser applies both, and the stricter result wins. See the Content Security Policy page for the other directives.
How do penetration testers test for clickjacking?#
Testers do two checks: read the headers, then frame the page from another origin. The headers check takes one command, and the framing check proves the browser behavior. We state the evidence standard below, and it follows the lab's usual rule that a finding needs evidence of a real effect.
Read the headers on the sensitive pages and on the home page:
curl -sI https://app.example.com/settings/delete-account | grep -i -E "content-security-policy|x-frame-options"Check the result. A
frame-ancestorsvalue that excludes your test origin, orX-Frame-Options: DENYorSAMEORIGIN, passes. No header, or onlyALLOW-FROM, fails.Host the one-file iframe page from a different origin than the target. Open it in a current Chrome and a current Firefox, with a test account signed in to the target.
Record the result. A blank frame with a console message is a pass. A loaded page is a fail. Save a screenshot, the header output and the browser versions.
Check whether the framed page does something sensitive with one click. Do the action on a test account and read the effect back from a second session.
Check cookies. With
SameSite=LaxorStrict, the framed page may load logged out. Record whether the session survived in the frame. If it did not, the impact is much lower.
Always get written authorization before testing a system, and test with your own accounts. Do not build the transparent overlay for a real application. The frame loading is enough evidence.
When is clickjacking a real finding?#
It is a real finding when a framed page lets a user take an action that has a cost, with one click, in a signed-in session. A missing header alone, on a page with nothing to click, is informational. This matches how we label evidence: a bare missing header is "Observed", and the finding is rated on the framed action.
| Situation | Rating guide |
|---|---|
| Header missing, page has no state-changing action | Informational |
| Header missing, login page or public page only | Informational or low |
| Header missing, a one-click change to account data, email or password is reachable and the session survives in the frame | Medium or higher, based on the action |
| Header missing, one click approves a payment, role change or API key | High, based on the action |
| Header present, DoubleClickjacking route exists | Report the action as the finding, and recommend server-enforced authorization |
State the action, the evidence that it works from a frame, and the fix. A report that says only "X-Frame-Options missing" gives the reader nothing to rank.
Testing checklist#
- Every HTML response sends
Content-Security-Policy: frame-ancestorswith'none','self'or a short list. -
X-Frame-OptionsisDENYorSAMEORIGINon the same responses. NoALLOW-FROM. - Both headers are real HTTP headers, not meta tags.
- Error pages, redirects with a body and file download pages carry the headers.
- No CDN, proxy or load balancer rule removes the headers.
- Each exemption in code (for example
xframe_options_exempt) is on a page that needs framing. - Session cookies set
SameSite=LaxorStrict. - High-risk actions need server-side re-authentication or a confirmation that a frame cannot click through.
- A frame test from a different origin was run in two browsers, with screenshots.
When should you get a professional test?#
Get one when your product has account, billing or admin actions that a single click can trigger, and you want proof of what a framed page can reach. Header checks are easy to automate. Finding which pages hold a one-click action, and whether the session survives in a frame, takes a tester with an account. Our web application penetration testing service includes browser-observed checks like this, and each finding in the report shows the request, the response and the screenshot.
Questions
What is the difference between clickjacking and CSRF?
CSRF forges a request from another site. The victim never sees your page. Clickjacking loads your real page and gets the victim to click on it. A CSRF token does not stop clickjacking, because the victim's click sends a request with a valid token from your own page.
Is X-Frame-Options still needed if I use frame-ancestors?
Modern browsers use frame-ancestors and ignore X-Frame-Options when both are present. OWASP still advises sending both for older browsers. Keep them consistent: DENY with 'none', SAMEORIGIN with 'self'.
Does JavaScript frame busting work?
Not reliably. OWASP lists ways to defeat the simple top.location check: double framing, onBeforeUnload prompts, 204 responses and the sandbox attribute. Use headers. Keep a script only as a fallback for very old browsers.
Can a page with no login be clickjacked?
Yes, it can be framed, but the impact is usually small. Clickjacking matters when a framed page lets a user do something that counts, such as change a setting, approve a request or delete data, with one click.
Do SameSite cookies stop clickjacking?
They reduce the impact. A Lax or Strict cookie is not sent with a cross-site iframe request, so the framed page loads as a logged-out page. They do not stop framing, and they do not help when the action needs no login. OWASP says not to rely on them alone.
Sources
- OWASP Cheat Sheet Series: Clickjacking Defense (checked 9 November 2026)
- OWASP Web Security Testing Guide: Testing for Clickjacking (checked 9 November 2026)
- OWASP Community: Clickjacking (checked 9 November 2026)
- PortSwigger Web Security Academy: Clickjacking (UI redressing) (checked 9 November 2026)
- MDN: X-Frame-Options (checked 9 November 2026)
- MDN: Content-Security-Policy frame-ancestors (checked 9 November 2026)
- CWE-1021: Improper Restriction of Rendered UI Layers or Frames (checked 9 November 2026)
- Chris Shiflett: Twitter Don't Click Exploit (12 February 2009) (checked 9 November 2026)
- Twitter blog: Clickjacking Blocked (2009) (checked 9 November 2026)
- Django documentation: Clickjacking protection (checked 9 November 2026)
- Helmet documentation (Express) (checked 9 November 2026)
- Spring Security reference: Servlet headers (checked 9 November 2026)
- Ruby on Rails Guides: Securing Rails applications (checked 9 November 2026)
- nginx documentation: ngx_http_headers_module (checked 9 November 2026)