# Clickjacking: how to test your site and fix it with headers

Source: https://penetrationtestservices.com/vulnerabilities/clickjacking/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

Clickjacking explained: how the hidden iframe attack works, a 5-minute test, frame-ancestors and X-Frame-Options config for 8 stacks, and when it is a real finding.

> **Short answer:** 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:
> 
> ```html
>          width="600" height="300">
> ```
> 
> 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.

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

**Apache.**

```apacheconf
Header always set Content-Security-Policy "frame-ancestors 'none'"
Header always set X-Frame-Options "DENY"
```

**IIS (web.config).**

```xml

  
    
    
  

```

**Node.js with Express and Helmet.** Helmet sends `X-Frame-Options: SAMEORIGIN` and a default policy with `frame-ancestors 'self'`. To refuse all framing:

```js
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:

```python
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`:

```ruby
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:

```java
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:

```php
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](/vulnerabilities/content-security-policy/) 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.

1. Read the headers on the sensitive pages and on the home page:

   ```bash
   curl -sI https://app.example.com/settings/delete-account | grep -i -E "content-security-policy|x-frame-options"
   ```

2. Check the result. A `frame-ancestors` value that excludes your test origin, or `X-Frame-Options: DENY` or `SAMEORIGIN`, passes. No header, or only `ALLOW-FROM`, fails.
3. 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.
4. 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.
5. 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.
6. Check cookies. With `SameSite=Lax` or `Strict`, 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-ancestors` with `'none'`, `'self'` or a short list.
- [ ] `X-Frame-Options` is `DENY` or `SAMEORIGIN` on the same responses. No `ALLOW-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=Lax` or `Strict`.
- [ ] 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](/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](https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html)
- [OWASP Web Security Testing Guide: Testing for Clickjacking](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/09-Testing_for_Clickjacking)
- [OWASP Community: Clickjacking](https://owasp.org/www-community/attacks/Clickjacking)
- [PortSwigger Web Security Academy: Clickjacking (UI redressing)](https://portswigger.net/web-security/clickjacking)
- [MDN: X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options)
- [MDN: Content-Security-Policy frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors)
- [CWE-1021: Improper Restriction of Rendered UI Layers or Frames](https://cwe.mitre.org/data/definitions/1021.html)
- [Chris Shiflett: Twitter Don't Click Exploit (12 February 2009)](https://shiflett.org/blog/2009/twitter-dont-click-exploit)
- [Twitter blog: Clickjacking Blocked (2009)](https://blog.x.com/en_us/a/2009/clickjacking-blocked)
- [Django documentation: Clickjacking protection](https://docs.djangoproject.com/en/5.2/ref/clickjacking/)
- [Helmet documentation (Express)](https://helmetjs.github.io/)
- [Spring Security reference: Servlet headers](https://docs.spring.io/spring-security/reference/servlet/exploits/headers.html)
- [Ruby on Rails Guides: Securing Rails applications](https://guides.rubyonrails.org/security.html)
- [nginx documentation: ngx_http_headers_module](https://nginx.org/en/docs/http/ngx_http_headers_module.html)
