Security reference · Clickjacking · UI security
MCP server clickjacking advanced security
Clickjacking attacks overlay an invisible or transparent frame over a legitimate MCP admin UI, tricking an authenticated user into performing unintended actions — approving a tool invocation, revoking a session, or confirming a dangerous operation. While X-Frame-Options is the standard defense, it has four known bypass classes that matter for MCP servers with admin interfaces: subframe navigation attacks (a child frame navigates to your admin URL, bypassing parent-level framing checks), portal element abuse (the HTML <portal> element allows embedding pages without X-Frame-Options enforcement in browsers that support it), framebusting bypass in sandboxed iframes (the sandbox attribute prevents your framebusting JavaScript from navigating away), and double-framing bypass for SAMEORIGIN (an outer frame at your own origin embeds a frame from a cross-origin attacker, which then loads your UI in a nested iframe that the SAMEORIGIN policy incorrectly allows). CSP frame-ancestors is the only mechanism that addresses all four.
Why X-Frame-Options alone is insufficient
X-Frame-Options has two values: DENY (no framing at all) and SAMEORIGIN (only frames from the same origin). It was designed for a simpler era of HTML and has two fundamental gaps:
No cascading check for SAMEORIGIN. The SAMEORIGIN check only examines the immediate parent frame — not the entire frame ancestry chain. A page at your-app.com can legitimately embed an iframe (from its own origin), and that iframe can load an <iframe src="your-app.com/admin"> as a grandchild. The grandchild sees the immediate parent as same-origin and allows framing, even though the outer attacker page initiated the chain.
No portal element coverage. The <portal> element (shipping in Chrome 85+ as an Origin Trial, enabled in some versions) allows seamless page-in-page embedding with smoother transitions than <iframe>. It doesn't go through the X-Frame-Options check in all implementations.
Attack 1: Double-framing SAMEORIGIN bypass
<!-- attacker.com/attack.html -->
<!-- Outer iframe at a same-origin page (requires a CORS-accessible iframe at victim origin,
or uses a same-origin page the attacker found with open redirect / XSS) -->
<iframe src="https://app.example.com/blank-page-that-accepts-nested-frame">
<!-- This page is at the same origin as /admin -->
<!-- Inside it, loads: -->
<iframe src="https://app.example.com/admin/delete-user?id=123"></iframe>
</iframe>
The inner /admin/delete-user frame's immediate parent is app.example.com/blank-page — same origin. The SAMEORIGIN check passes. The outer attacker frame is invisible; it overlays a fake "Confirm" button over the Delete User button in the inner frame.
CSP frame-ancestors catches this. The frame-ancestors directive checks the full ancestry chain — every frame in the hierarchy must match an allowed origin, not just the immediate parent.
Attack 2: Framebusting bypass via sandbox attribute
Framebusting is a JavaScript technique that detects when the page is loaded inside an iframe and navigates the top window to break out:
// Common framebusting pattern in legacy MCP admin UIs
if (window.top !== window.self) {
window.top.location = window.self.location;
}
This is bypassed by the sandbox attribute, which restricts what the framed page's JavaScript can do. Specifically, without allow-top-navigation, the framebusting script cannot navigate window.top:
<!-- attacker.com — framebusting bypass -->
<iframe
src="https://app.example.com/admin/approve-tool"
sandbox="allow-scripts allow-forms"
>
<!-- allow-scripts lets the page's JS run, but without allow-top-navigation
the framebusting script's window.top.location assignment silently fails -->
</iframe>
The admin page loads, its framebusting script runs, but window.top.location = ... is blocked by the sandbox. The attacker's frame stays in place.
Framebusting JavaScript is not a reliable clickjacking defense. It can be bypassed via sandbox, disabled by JavaScript errors, or defeated by timing attacks. Use CSP frame-ancestors instead — it's enforced by the browser before JavaScript executes.
Attack 3: Portal element embedding
The HTML <portal> element provides seamless navigation with the appearance of a smooth page transition. In browsers that implement it, <portal> does not enforce X-Frame-Options in the same way as <iframe>:
<!-- attacker.com — portal element bypass (Chrome with portal origin trial) -->
<portal src="https://app.example.com/admin/settings"
style="width:100%;height:100%;position:fixed;top:0;left:0;opacity:0.01">
</portal>
<!-- Fake UI overlaid on top -->
<div style="position:fixed;top:200px;left:200px">
<button onclick="document.querySelector('portal').activate()">
Click to claim your reward
</button>
</div>
When the user clicks the fake button, portal.activate() navigates to the admin settings page — performing whatever action the attacker positioned under the fake button.
CSP frame-ancestors 'none' covers portal elements in browsers that respect it for portals. As an additional defense, serve the admin UI with Permissions-Policy: portal=() to explicitly disable portal embedding:
# Caddy — complete anti-framing headers for MCP admin endpoints
@admin {
path /admin/*
}
header @admin {
Content-Security-Policy "frame-ancestors 'none'"
X-Frame-Options "DENY"
Permissions-Policy "portal=()"
}
Attack 4: Subframe navigation to bypass top-level X-Frame-Options
An attacker page can load an innocent-looking page as an <iframe> (one that doesn't set X-Frame-Options), then navigate that iframe's location to the target admin URL using JavaScript. Since X-Frame-Options is checked at load time and not on subsequent navigations, the iframe's initial benign load passes the check — and then the attacker's script navigates it to the protected resource:
<!-- attacker.com -->
<iframe id="f" src="https://example.com/no-xfo-page"></iframe>
<script>
setTimeout(() => {
// After the innocent page loads, navigate the frame to the admin UI
// X-Frame-Options is NOT rechecked on same-origin navigation within a frame
document.getElementById("f").contentWindow.location =
"https://app.example.com/admin/delete-user?id=123";
}, 100);
</script>
This only works if the initial innocent page and the target admin page are same-origin (so the outer frame has cross-origin access to navigate the inner frame's location), which limits its practical scope. But combined with an XSS on a same-origin page with no X-Frame-Options, it becomes a viable attack chain.
The complete defense: CSP frame-ancestors
Content-Security-Policy: frame-ancestors is the correct and complete clickjacking defense. Unlike X-Frame-Options, it:
- Checks the full frame ancestry chain, not just the immediate parent
- Cannot be bypassed by sandboxed iframes
- Is respected for portal elements in compliant browsers
- Cannot be defeated by subframe navigation (checked on load and on navigation)
- Supports URL patterns and
'self'keyword (X-Frame-Options SAMEORIGIN is equivalent toframe-ancestors 'self')
// Express — set CSP frame-ancestors for admin routes
app.use("/admin", (req, res, next) => {
// 'none' = no framing allowed at all (equivalent to X-Frame-Options: DENY)
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
// Keep X-Frame-Options as a fallback for very old browsers
res.setHeader("X-Frame-Options", "DENY");
next();
});
// For the main app that needs to allow embedding in your own app:
app.use("/embed", (req, res, next) => {
// Only allow framing from specific trusted origins
res.setHeader(
"Content-Security-Policy",
"frame-ancestors 'self' https://trusted-partner.com"
);
next();
});
Comparison: X-Frame-Options vs. CSP frame-ancestors
| Attack vector | X-Frame-Options DENY | CSP frame-ancestors 'none' |
|---|---|---|
| Basic iframe embedding | Blocks | Blocks |
| Double-framing SAMEORIGIN bypass | Bypassed (only checks immediate parent) | Blocks (checks full ancestry) |
| Sandboxed iframe framebusting bypass | Only if DENY is set (no JS involved) | Blocks (enforced before JS executes) |
| Portal element embedding | Not enforced by all browsers | Blocks in CSP-compliant implementations |
| Subframe navigation to target | Limited coverage | Blocks on navigation |
| Browser support | Universal (including IE) | All modern browsers (CSP Level 2+) |
Recommendation: serve both X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'. The CSP provides the stronger guarantee; X-Frame-Options serves as a fallback for ancient browsers that don't support CSP.
SkillAudit findings
Run a clickjacking security audit. SkillAudit checks for missing CSP frame-ancestors, X-Frame-Options coverage on all admin endpoints, portal element protection, and framebusting script patterns on MCP server admin interfaces. Audit your server →