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:

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:
//