🪟 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 a
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:
- Can another site frame the page that holds the action?
- Does the framed page arrive logged in?
- Is there an action on it that one or two clicks can finish, and that matters?
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: DENYorSAMEORIGINis the older header.
The rules that trip people up, all from the CSP spec, MDN and the OWASP cheat sheet:
-
frame-ancestorswins. If an enforced CSP containsframe-ancestors, the browser ignoresX-Frame-Options. -
ALLOW-FROMdoes nothing. MDN says modern browsers that see it ignore the whole header, soX-Frame-Options: ALLOW-FROM https://partner.example.commeans no protection at all. -
<meta>doesn't count. Neither header works from a meta tag; it must be a real response header. -
default-srcdoesn't cover it.frame-ancestorshas no fallback, sodefault-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()))
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'
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)
(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, yourSameSite=Laxcookie goes along to a frame ofapp.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=Noneis 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
sandboxattribute; omittingallow-top-navigationstops 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>
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:
- 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. - Click the decoy once (or twice, for multistep).
- 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.
- 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;
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" },
}),
);
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],
}
Then the layers that don't depend on headers:
- Keep the session cookie
SameSite=LaxorStrictunless 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:
- Basic clickjacking with CSRF token protection
- Clickjacking with form input data prefilled from a URL parameter
- Clickjacking with a frame buster script
- Exploiting clickjacking vulnerability to trigger DOM-based XSS
- Multistep clickjacking
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
- OWASP Clickjacking Defense Cheat Sheet
- PortSwigger: Clickjacking (UI redressing)
- PortSwigger: Bypassing SameSite cookie restrictions
- MDN: CSP frame-ancestors and X-Frame-Options
- MDN: Set-Cookie, SameSite
- W3C CSP Level 3: Relation to X-Frame-Options
- CWE-1021: Improper Restriction of Rendered UI Layers or Frames
- Bugcrowd Vulnerability Rating Taxonomy
- Django: Clickjacking protection and Content Security Policy
- Helmet README
- nginx: add_header
- Paulos Yibelo: DoubleClickjacking
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/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.