DEV Community

Cover image for πŸͺŸ Clickjacking: When a Missing X-Frame-Options Is a Real Bug
Krishna Kumar
Krishna Kumar

Posted on

πŸͺŸ Clickjacking: When a Missing X-Frame-Options Is a Real Bug

Every scanner flags it: "Missing X-Frame-Options header." Many programs close it as informative, and they're often right. Bugcrowd's Vulnerability Rating Taxonomy splits clickjacking into a "non-sensitive action" entry and a "sensitive click-based action" entry, and only the second one is rated above the bottom tier.

So the header on its own isn't the bug. The bug is a page that another site can frame, that arrives logged in, and that holds an action one click can complete. This post walks through how to check those three things, how to prove the result on your own test account, and how to fix it so it stays fixed.

Only test applications you own or are explicitly authorized to test. Every host below is a stand-in.

What clickjacking actually is

Clickjacking (CWE-1021, "Improper Restriction of Rendered UI Layers or Frames") loads the target page in an invisible iframe on top of a decoy page. The user thinks they're clicking "Claim prize". They're clicking "Delete account" on the real site, inside their real session.

That last part is why CSRF tokens don't help. As PortSwigger points out, the click happens on the genuine page, so the genuine token goes along with it. Nothing is forged; the user is just tricked into pressing the button themselves.

That gives three questions, and you need a "yes" to all three:

  1. Can another site frame the page that holds the action?
  2. Does the framed page arrive logged in?
  3. Is there an action on it that one or two clicks can finish, and that matters?

Three checks between a missing header and a real clickjacking bug

Check 1: can the action page be framed?

Check the response of the page that has the button, not the homepage. Framing protection is per response, and it's common to see it on the marketing pages and missing on /settings.

Two headers control framing:

  • Content-Security-Policy: frame-ancestors ... lists who may embed the page. 'none' blocks everyone, 'self' allows only the same origin.
  • X-Frame-Options: DENY or SAMEORIGIN is the older header.

The rules that trip people up, all from the CSP spec, MDN and the OWASP cheat sheet:

  • frame-ancestors wins. If an enforced CSP contains frame-ancestors, the browser ignores X-Frame-Options.
  • ALLOW-FROM does nothing. MDN says modern browsers that see it ignore the whole header, so X-Frame-Options: ALLOW-FROM https://partner.example.com means no protection at all.
  • <meta> doesn't count. Neither header works from a meta tag; it must be a real response header.
  • default-src doesn't cover it. frame-ancestors has no fallback, so default-src 'none' still lets anyone embed the page.
  • Report-Only only reports. Content-Security-Policy-Report-Only: frame-ancestors 'none' monitors violations without enforcing them.

Here's a small triage script with those rules built in. It reads the headers and tells you whether the page is worth a closer look:

# frame_check.py: triage only. A real browser has the final say.
import sys
import urllib.request


def framing_verdict(headers):
    """headers: list of (name, value) pairs from ONE response."""
    for name, value in headers:
        if name.lower() != "content-security-policy":
            continue
        for directive in value.split(";"):
            parts = directive.split()
            if parts and parts[0].lower() == "frame-ancestors":
                sources = " ".join(parts[1:])
                if sources == "*":
                    return "FRAMEABLE: frame-ancestors * lets any site frame it"
                return f"restricted by CSP: frame-ancestors {sources}"
    xfo = [v.strip() for n, v in headers if n.lower() == "x-frame-options"]
    if len({v.upper() for v in xfo}) > 1:
        return f"UNCLEAR: conflicting X-Frame-Options {xfo}, test in a browser"
    if xfo and xfo[0].upper() in ("DENY", "SAMEORIGIN"):
        return f"restricted by X-Frame-Options: {xfo[0]}"
    note = ""
    if xfo:
        note = f" (X-Frame-Options {xfo[0]!r} is ignored by modern browsers)"
    if any(n.lower() == "content-security-policy-report-only" for n, _ in headers):
        note += " (a Report-Only policy reports, it doesn't block)"
    return "FRAMEABLE: no enforced framing control" + note


def samesite(set_cookie_line):
    attrs = [a.strip().lower() for a in set_cookie_line.split(";")[1:]]
    for a in attrs:
        if a.startswith("samesite="):
            v = a.split("=", 1)[1]
            if v == "none":
                return "None: sent inside a cross-site iframe"
            return f"{v.title()}: withheld from a cross-site iframe"
    return "not set: browser default (Chrome treats it as Lax; check yours)"


if __name__ == "__main__":
    for url in sys.argv[1:]:
        with urllib.request.urlopen(url) as resp:
            print(url, "->", framing_verdict(resp.headers.items()))
Enter fullscreen mode Exit fullscreen mode

I ran it against a local mock server that returns a different set of headers on each path:

/settings/none        -> FRAMEABLE: no enforced framing control
/settings/xfo-deny    -> restricted by X-Frame-Options: DENY
/settings/allow-from  -> FRAMEABLE: no enforced framing control (X-Frame-Options 'ALLOW-FROM https://partner.example.com' is ignored by modern browsers)
/settings/csp-self    -> restricted by CSP: frame-ancestors 'self'
/settings/csp-no-fa   -> FRAMEABLE: no enforced framing control
/settings/report-only -> FRAMEABLE: no enforced framing control (a Report-Only policy reports, it doesn't block)
/settings/both        -> restricted by CSP: frame-ancestors 'none'
Enter fullscreen mode Exit fullscreen mode

csp-no-fa is the one people miss: it sends default-src 'none', looks locked down, and can still be framed.

Two limits. The script fetches without your cookies, and some apps only add headers to logged-in responses or redirect logged-out users elsewhere, so check the authenticated response in your proxy as well. And a frame-ancestors list that names other hosts is only as strong as those hosts. If it allows https://*.example.com, any subdomain that can host attacker HTML puts you back in business.

A "FRAMEABLE" verdict is a lead. It isn't a finding yet.

Check 2: does the framed page arrive logged in?

This is the check most clickjacking reports skip, and it's often where they fall apart.

The framed page loads from the attacker's site, which makes it a cross-site request. MDN's Set-Cookie reference is explicit: SameSite=Lax cookies aren't sent for navigations inside iframes, and Strict cookies are never sent cross-site. If the session cookie is withheld, the victim sees the login page inside your invisible frame, and there's nothing to click.

So look at the Set-Cookie line for the session cookie, usually on the login response:

session=abc123; Path=/; Secure; HttpOnly; SameSite=None  -> None: sent inside a cross-site iframe
session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax   -> Lax: withheld from a cross-site iframe
session=abc123; Path=/; Secure; HttpOnly                 -> not set: browser default (Chrome treats it as Lax; check yours)
Enter fullscreen mode Exit fullscreen mode

(Same samesite() function from the script above, same local run.)

A missing SameSite attribute is browser-dependent. PortSwigger notes that Chrome applies Lax by default, and MDN says only that "some browsers" do. Test in the browser your users actually use and write down which one in your report.

Three exceptions keep this check from being a free pass:

  • Same-site isn't same-origin. The OWASP cheat sheet points out that a sibling subdomain on the same scheme counts as the same site. If an attacker can put HTML on files.example.com, your SameSite=Lax cookie goes along to a frame of app.example.com.
  • Some actions don't need a session. A logged-out form, a page that authenticates with a token in the URL, or an embedded widget can still be abused.
  • SameSite=None is common on purpose. Apps that are meant to be embedded, or that use cross-site login flows, often set it. Those are exactly the apps where the framing headers matter most.

Check 3: is there a click worth stealing?

Now look at the page itself. A strong candidate has a state change that completes in one or two clicks with no typing: delete the account, confirm a payment, approve an OAuth grant, turn off two-factor, make a private item public, add a collaborator.

PortSwigger's labs cover the patterns that turn a "needs typing" form into a one-click action:

  • Prefilled input. If the form accepts its values from URL parameters (/settings/email?email=...), the attacker supplies the value and the victim only clicks "Update".
  • Frame busters. A script that tries to break out of frames can usually be neutralized with the iframe sandbox attribute; omitting allow-top-navigation stops it navigating the top window.
  • Confirmation dialogs. An in-page "Are you sure?" just becomes a second decoy click.

If the only thing on the page is a "like" button or a newsletter toggle, that's the "non-sensitive action" entry, and the honest report says so.

Prove it on your own account

With all three checks passing, build the proof against your own test account. Detection isn't proof here either: "the page loads in an iframe" shows a missing header. It doesn't show that a user can be made to do something.

Serve this from a different site than the target (a local file server on localhost works when the target is a staging host):

<!-- poc.html: authorized testing only, stand-in host -->
<style>
  iframe {
    position: relative;
    width: 1000px;
    height: 700px;
    opacity: 0.3;   /* visible on purpose: the evidence should show the overlap */
    z-index: 2;
  }
  .decoy {
    position: absolute;
    top: 455px;     /* adjust until it sits under the target button */
    left: 60px;
    z-index: 1;
  }
</style>
<div class="decoy">Claim your reward</div>
<iframe src="https://app.example.com/settings/account"></iframe>
Enter fullscreen mode Exit fullscreen mode

This is the same layering PortSwigger teaches. Burp Suite's Clickbandit can generate it for you by recording the clicks you make on the framed page.

Then run it in a normal browser profile where your test account is logged in:

  1. Open poc.html. Note whether the framed page shows you logged in. If it shows a login form, check 2 failed; stop and say so.
  2. Click the decoy once (or twice, for multistep).
  3. Confirm the state change on the server, from the account itself: the email really changed, the item really became public, the session list really shows the new grant.
  4. Record a short screen capture with the 0.3 opacity so a reviewer sees both layers.

Your report then contains: the response headers of the action page, the session cookie's SameSite value, the browser and version, the PoC HTML, the capture, and the before-and-after state of your own account. That's a finding a triager can reproduce in two minutes, and the severity follows from the action you proved, not from the header.

The fix

Send framing protection on every HTML response, not page by page:

# nginx: "always" also adds them on error responses
add_header Content-Security-Policy "frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
Enter fullscreen mode Exit fullscreen mode

One nginx gotcha: add_header directives are inherited from the previous level only if the current level has none. A single add_header inside a location block silently drops the server-level ones for that location.

In Express, Helmet's defaults already send frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN. To deny all framing:

import express from "express";
import helmet from "helmet";

const app = express();
app.use(
  helmet({
    contentSecurityPolicy: { directives: { frameAncestors: ["'none'"] } },
    xFrameOptions: { action: "deny" },
  }),
);
Enter fullscreen mode Exit fullscreen mode

In Django, XFrameOptionsMiddleware is in the settings that startproject generates and sends DENY by default. Watch for @xframe_options_exempt on views that hold sensitive actions. Django 6.0 added built-in CSP support, so you can add frame-ancestors too:

# settings.py (Django 6.0+), with
# "django.middleware.csp.ContentSecurityPolicyMiddleware" in MIDDLEWARE
from django.utils.csp import CSP

SECURE_CSP = {
    "default-src": [CSP.SELF],
    "frame-ancestors": [CSP.NONE],
}
Enter fullscreen mode Exit fullscreen mode

Then the layers that don't depend on headers:

  • Keep the session cookie SameSite=Lax or Strict unless you really need cross-site embedding.
  • For high-risk actions, require something a click can't supply: re-entering the password, a second factor, or typing the resource name.
  • Don't rely on frame-busting JavaScript; the OWASP cheat sheet lists several ways around it.

One limit worth knowing: Paulos Yibelo's DoubleClickjacking research shows a variant that uses a separate top-level window instead of an iframe, so framing headers don't stop it. OWASP's current guidance for that case is server-enforced transaction authorization on sensitive actions, which is one more reason to put a real confirmation step behind them.

Finally, add framing_verdict() (or an equivalent header assertion) to your test suite for the sensitive routes, so a future refactor that drops the header fails a build instead of reaching production.

Try it yourself

Only test what you own or are authorized to test. The PortSwigger Web Security Academy has five free clickjacking labs that map onto the checks above:

For check 1 and check 2, point frame_check.py at an app you run locally, then flip the headers and the cookie's SameSite value and watch the verdict change. Open the PoC in your browser after each change; when the framed page shows a login form, you've seen check 2 fail for yourself.

Wrapping up

A missing X-Frame-Options header covers one of three conditions, at most. Before reporting clickjacking, confirm the action page can be framed, that it arrives logged in, and that a click there does something that matters. Then prove it on your own account and show the state change. If any check fails, the honest answer is "hardening note", and that's fine too.

Further reading

I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.


πŸ” If this was useful, follow me for more hands-on web & API security write-ups.

πŸ”— Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/

Top comments (1)

Collapse
 
lin7c profile image
xcl •

The three-check framing is a useful triage lens, especially check 2. One thing worth adding from experience: even with SameSite=Lax on the session cookie, a same-site subdomain that hosts user content can still frame the app and send the cookie along, so the cookie check alone can give false confidence when subdomains are in scope. Do you treat subdomain takeover or user-content hosting as part of check 2, or as a separate precondition when scoping a report? Also, the nginx add_header inheritance gotcha you mentioned is a classic β€” have you seen a case where the header assertion test you suggest at the end caught a regression that a scanner missed?