Topic: mcp server clickjacking security
MCP server clickjacking security — frame protection for MCP web interfaces
Clickjacking (UI redress attack) embeds a target page in a transparent iframe positioned over a deceptive page. The user thinks they're clicking on the outer page's buttons but they're actually clicking through to the embedded target. For MCP servers with web interfaces — OAuth consent screens, admin dashboards, configuration panels — clickjacking enables an attacker to trick users into approving OAuth grants, changing server settings, or triggering sensitive operations. The defense is a two-header combination: X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none', plus SameSite=Strict on session cookies.
Why MCP servers are clickjacking targets
Most pure stdio MCP servers (no HTTP transport) have no web interface and are not clickjacking targets. The vulnerability is relevant for MCP servers that include any of the following:
- An HTTP or SSE transport with a browser-accessible endpoint
- An OAuth consent/authorization screen that users visit in a browser
- An admin panel or configuration UI served alongside the MCP server
- A setup wizard or installation flow that runs in a browser
The highest-risk page type is the OAuth consent screen: a user clicking "Authorize" on a clickjacked OAuth page grants permissions to an application without realizing it. In the MCP context this means a user could unknowingly grant an MCP server access to their GitHub repositories, calendar data, or email.
Four clickjacking defense patterns for MCP server HTTP layers
1. Express middleware — X-Frame-Options + CSP frame-ancestors
import express from 'express'
const app = express()
// Apply to ALL routes — clickjacking protection should be global
app.use((req, res, next) => {
// Primary defense: CSP frame-ancestors (supported by all modern browsers)
// 'none' = this page cannot be embedded in any iframe, frame, or object
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
// Compatibility defense: X-Frame-Options for older browsers
// DENY = block all framing (more restrictive than SAMEORIGIN)
res.setHeader('X-Frame-Options', 'DENY')
// Prevent MIME type sniffing (defense in depth)
res.setHeader('X-Content-Type-Options', 'nosniff')
next()
})
// If you need to allow embedding within your own origin (e.g., a widget):
// res.setHeader('Content-Security-Policy', "frame-ancestors 'self'")
// res.setHeader('X-Frame-Options', 'SAMEORIGIN')
// Caution: 'self' allows ANY page on your origin to embed any other page on your origin
// For specific pages (e.g., an embeddable dashboard widget) that SHOULD be frameable:
// Apply the permissive policy only to those specific routes, not globally
app.get('/widget/embed', (req, res) => {
// Override the global header for this specific embed-permitted route
res.setHeader('Content-Security-Policy', "frame-ancestors https://app.partner.com")
res.setHeader('X-Frame-Options', 'ALLOW-FROM https://app.partner.com')
// Note: ALLOW-FROM is deprecated and ignored by most modern browsers
// For cross-origin embedding support, use only CSP frame-ancestors with explicit origins
res.send(widgetHtml)
})
2. SameSite=Strict on session cookies
import session from 'express-session'
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
// SameSite=Strict: session cookie is NOT sent in cross-site requests
// This means even if the page is framed, the session is not available
// Attacker must rely on the user already being authenticated in the target origin
sameSite: 'strict',
// Secure: cookie only sent over HTTPS
secure: true,
// HttpOnly: cookie not accessible to JavaScript (prevents XSS cookie theft)
httpOnly: true,
// Short expiry for sensitive operations
maxAge: 8 * 60 * 60 * 1000, // 8 hours
},
}))
// SameSite=Lax (default in modern browsers) is weaker than Strict:
// Lax cookies ARE sent in top-level navigations (links clicked) but not in iframes
// Strict cookies are NOT sent in ANY cross-site context including top-level navigation
// For OAuth flows, Strict can break redirects back from the OAuth provider
// Solution: use SameSite=Lax for the session cookie, but add CSRF tokens
// for all state-changing operations
3. Frame-busting JavaScript (defense in depth, not a replacement)
// JavaScript frame-busting is NOT a replacement for X-Frame-Options / CSP frame-ancestors
// It can be bypassed by the sandbox attribute on the iframe element
// Use it as defense-in-depth only
// In your HTML template for sensitive pages (OAuth consent, admin panel):
const frameBustingScript = `
`
// Why the style+redirect pattern instead of just "if (self !== top) top.location = ...":
// Some browsers delay script execution; the style:display:none ensures the user
// sees nothing until the frame-bust redirect completes
// IMPORTANT: This is bypassed by:
//
4. Helmet.js — one-line Express integration
// npm install helmet
import helmet from 'helmet'
import express from 'express'
const app = express()
// helmet() applies a collection of security headers including:
// - X-Frame-Options: SAMEORIGIN (by default)
// - Content-Security-Policy with frame-ancestors 'self' (configurable)
// - X-Content-Type-Options: nosniff
// - Strict-Transport-Security (HSTS)
// - and more
app.use(helmet({
frameguard: {
action: 'deny', // X-Frame-Options: DENY (more restrictive than helmet's default SAMEORIGIN)
},
contentSecurityPolicy: {
directives: {
frameAncestors: ["'none'"], // CSP frame-ancestors: 'none'
// ... other CSP directives
},
},
}))
// To allow embedding within your own origin only:
// frameguard: { action: 'sameorigin' }
// contentSecurityPolicy: { directives: { frameAncestors: ["'self'"] } }
What SkillAudit checks
- Missing X-Frame-Options header on HTTP MCP server responses — WARN; page can be framed without explicit browser-level prevention
- Missing CSP frame-ancestors directive on HTTP MCP server responses — WARN; X-Frame-Options alone is insufficient for modern browser security guarantees
- Both X-Frame-Options and CSP frame-ancestors missing — HIGH; page can be freely embedded; any OAuth consent or admin action is a clickjacking target
- Session cookies without SameSite=Strict or SameSite=Lax — WARN; cross-site requests include session credentials, enabling authenticated clickjacking
- X-Frame-Options: ALLOW-FROM without corresponding CSP frame-ancestors — WARN; ALLOW-FROM is deprecated and ignored by Chrome, Firefox, and Safari
Scope note: stdio-only servers
Pure stdio MCP servers (no HTTP transport layer, no browser-accessible endpoints) have no web surface and are not clickjacking targets. SkillAudit's clickjacking checks apply only to servers with an HTTP or SSE transport that serves pages in a browser context. If your server is stdio-only, this finding will not appear in your audit report.
See also
- MCP server Content Security Policy — full CSP configuration beyond frame-ancestors
- MCP server CORS security — cross-origin resource sharing configuration
- MCP server OAuth2 security — OAuth consent screen attack surface
- MCP server SSE security — authentication for HTTP/SSE transport MCP servers
- MCP server security checklist — comprehensive pre-submission checklist
Check your MCP server web interface for missing frame protection headers.
Run a free audit → How grading works →