Skip to content

Clickjacking & Frame Security

expert18 min read

The Invisible Click

Clickjacking is a UI redress attack where the attacker tricks you into clicking something you didn't intend to click. The mechanism is simple: load the target page in a transparent iframe, overlay it on top of a decoy page, and position the iframe so the victim's click on the decoy actually hits a button on the target page.

You think you're clicking "Play Video." You're actually clicking "Delete Account" on a page you're logged into.

<!-- Attacker's page -->
<style>
  iframe {
    position: absolute;
    top: -300px;
    left: -200px;
    width: 1000px;
    height: 800px;
    opacity: 0.0001; /* invisible but still clickable */
    z-index: 999;
  }
</style>
<h1>Click to play the video!</h1>
<iframe src="https://your-bank.com/settings?action=disable-2fa"></iframe>

The iframe is transparent (opacity: 0.0001 — not 0 because some browsers optimize display:none and opacity:0 elements to not receive clicks). It's positioned so the bank's "Confirm" button sits exactly where the "Play Video" text appears to be.

Mental Model

Think of clickjacking like a two-way mirror trick. The attacker puts their decoy page behind a two-way mirror (the transparent iframe). You see the decoy and think you're interacting with it. But the mirror is actually the target site — your clicks pass through to the real application behind the glass. The defense is refusing to be put behind the mirror in the first place: tell the browser "never render my page inside someone else's iframe."

X-Frame-Options: The Legacy Defense

The X-Frame-Options response header was the first defense against clickjacking. It tells the browser whether the page can be rendered in a frame.

X-Frame-Options: DENY

Three values:

  • DENY — Cannot be framed by anyone, including your own domain
  • SAMEORIGIN — Can only be framed by pages from the same origin
  • ALLOW-FROM https://trusted.com — Can be framed only by the specified origin (deprecated, inconsistent browser support)

Limitations of X-Frame-Options

  1. No multiple originsALLOW-FROM only accepts one origin. If you need multiple trusted framers, you're out of luck.
  2. ALLOW-FROM is deprecated — Chrome never supported it. Firefox dropped support. Only Internet Explorer honored it.
  3. No wildcard/subdomain support — You can't allow *.example.com.
  4. Superseded by CSPframe-ancestors in CSP is the modern replacement with more flexibility.
Quiz
Why is the X-Frame-Options ALLOW-FROM directive unreliable for production use?

CSP frame-ancestors: The Modern Defense

frame-ancestors is a CSP directive that controls which origins can embed your page in an iframe (or object, embed, etc.). It replaces X-Frame-Options with more flexibility.

Content-Security-Policy: frame-ancestors 'self' https://trusted-app.com https://*.example.com

frame-ancestors vs X-Frame-Options

FeatureX-Frame-Optionsframe-ancestors
Multiple originsNoYes
Subdomain wildcardsNoYes (https://*.example.com)
Browser supportUniversal (legacy)All modern browsers
GranularityPer-responsePer-response
Overrides XFO?-Yes (frame-ancestors takes priority)

frame-ancestors Values

frame-ancestors 'none'                     /* nobody can frame this page */
frame-ancestors 'self'                     /* only same-origin */
frame-ancestors https://app.example.com    /* specific origin */
frame-ancestors https://*.example.com      /* subdomain wildcard */
frame-ancestors https://a.com https://b.com /* multiple origins */
frame-ancestors is special

Unlike most CSP directives, frame-ancestors does NOT fall back to default-src. If you set default-src 'none' but don't include frame-ancestors, your page can still be framed. You must set frame-ancestors explicitly.

Quiz
If your CSP sets default-src to none but does not include a frame-ancestors directive, can your page be embedded in an iframe?

The iframe sandbox Attribute

When you embed third-party content in your iframes, the sandbox attribute restricts what the embedded content can do:

<!-- Maximum restriction — blocks almost everything -->
<iframe src="https://untrusted.com" sandbox></iframe>

<!-- Selective permissions -->
<iframe
  src="https://payment-form.com"
  sandbox="allow-scripts allow-forms allow-same-origin"
></iframe>

Sandbox Permissions

PermissionWhat it allows
allow-scriptsJavaScript execution
allow-formsForm submission
allow-same-originTreat as same-origin (access cookies, storage)
allow-popupsOpening new windows/tabs
allow-top-navigationNavigate the parent page
allow-modalsShow alert/confirm/prompt dialogs
allow-downloadsTrigger file downloads
Common Trap

Never combine allow-scripts and allow-same-origin for untrusted content. Together, they let the embedded page remove the sandbox attribute from its own iframe via JavaScript, effectively escaping the sandbox entirely. Either of these permissions alone is safe — the combination creates an escape hatch.

When to Use sandbox

  • Embedding user-generated content (comments with HTML, user profiles)
  • Third-party widgets (payment forms, social embeds, ads)
  • Preview panes (email preview, markdown preview)
  • Any untrusted content that needs to be rendered in your page
<!-- User-generated HTML content — very restrictive -->
<iframe
  srcdoc="<p>User comment with <b>HTML</b></p>"
  sandbox=""
  style="border: none; width: 100%;"
></iframe>

Using srcdoc with an empty sandbox (no permissions) creates the most restrictive environment: no scripts, no forms, no popups, no navigation. The HTML renders but can't do anything dangerous.

Quiz
Why should you never use sandbox with both allow-scripts and allow-same-origin for untrusted content?

postMessage Security: The Cross-Origin Bridge

window.postMessage is the legitimate API for cross-origin communication between windows. It's essential for embedded widgets, OAuth popups, and cross-domain integrations. But it's also a common source of vulnerabilities when the origin isn't validated.

The Vulnerability: Missing Origin Validation

// VULNERABLE: accepts messages from any origin
window.addEventListener('message', (event) => {
  // No origin check — any page can send messages here
  const data = JSON.parse(event.data)
  if (data.action === 'updateProfile') {
    updateUserProfile(data.payload) // attacker controls the payload
  }
})

An attacker opens your page in an iframe (if framing is allowed) or opens a popup to your page, then sends crafted messages:

// On attacker's page
const target = window.open('https://your-app.com')
target.postMessage(JSON.stringify({
  action: 'updateProfile',
  payload: { role: 'admin' }
}), '*')

Secure postMessage Pattern

const TRUSTED_ORIGINS = new Set([
  'https://widget.example.com',
  'https://auth.example.com',
])

window.addEventListener('message', (event) => {
  if (!TRUSTED_ORIGINS.has(event.origin)) return

  let data
  try {
    data = JSON.parse(event.data)
  } catch {
    return
  }

  if (!data || typeof data.action !== 'string') return

  switch (data.action) {
    case 'widgetReady':
      handleWidgetReady(data)
      break
    case 'paymentComplete':
      handlePaymentComplete(data)
      break
  }
})

Key defenses:

  • Validate event.origin against an explicit allowlist
  • Parse safely — wrap JSON.parse in try/catch
  • Validate the data structure — don't trust the shape of the message
  • Use specific actions — don't route to arbitrary handlers

Sending Messages Securely

// VULNERABLE: * allows any page to intercept the message
iframe.contentWindow.postMessage(data, '*')

// SECURE: target the specific origin
iframe.contentWindow.postMessage(data, 'https://widget.example.com')

Always specify the target origin as the second argument. Using '*' means any page can receive the message if it's the iframe's content window. If the iframe navigates to an attacker's page (through a redirect or a bug), the attacker receives your data.

Quiz
What is the primary security check you must perform when receiving a postMessage event?

Production Scenario: Embedding a Third-Party Payment Widget

You need to embed a payment provider's form in your checkout page. Here's a secure implementation:

<iframe
  id="payment-frame"
  src="https://pay.provider.com/checkout?merchant=abc123"
  sandbox="allow-scripts allow-forms"
  allow="payment"
  style="width: 100%; height: 400px; border: none;"
  title="Payment form"
></iframe>
const PAYMENT_ORIGIN = 'https://pay.provider.com'

window.addEventListener('message', (event) => {
  if (event.origin !== PAYMENT_ORIGIN) return

  let message
  try {
    message = JSON.parse(event.data)
  } catch {
    return
  }

  if (message?.type === 'payment_success' && typeof message.transactionId === 'string') {
    verifyPaymentOnServer(message.transactionId)
  }
})

Security layers in this implementation:

  • sandbox="allow-scripts allow-forms" — the payment form can run JS and submit forms but cannot navigate the parent, open popups, or access the parent's cookies (no allow-same-origin)
  • Origin validation on postMessage — only messages from pay.provider.com are processed
  • Server-side verification — the transaction ID is verified on your server, not trusted from the client
  • title attribute — accessibility: screen readers announce the iframe's purpose
What developers doWhat they should do
Using postMessage with targetOrigin set to * when sending sensitive data
Using * as the target origin means any page that happens to be in the receiving window (including after a redirect to an attacker's page) can read the message. Specifying the exact origin ensures only the intended recipient receives the data.
Always specifying the exact target origin when calling postMessage
Only setting X-Frame-Options without CSP frame-ancestors
X-Frame-Options is legacy and limited (no multi-origin, no wildcards). frame-ancestors in CSP is the modern standard with more flexibility. Set both for backward compatibility — frame-ancestors takes priority in browsers that support it.
Setting both X-Frame-Options and frame-ancestors for defense in depth
Using sandbox with allow-scripts and allow-same-origin together for untrusted content
The combination allows the embedded page to escape the sandbox by removing the sandbox attribute from its own iframe via JavaScript. Separately, each permission is safe — together, they are an escape hatch.
Using either allow-scripts OR allow-same-origin, never both, for untrusted iframes

Challenge: Secure the postMessage Handler

Challenge: Secure this postMessage handler

Try to solve it before peeking at the answer.

window.addEventListener('message', (event) => {
  const data = JSON.parse(event.data)

  if (data.type === 'setUser') {
    currentUser = data.user
  } else if (data.type === 'navigate') {
    window.location.href = data.url
  } else if (data.type === 'render') {
    document.getElementById('output').innerHTML = data.html
  }
})
Key Rules
  1. 1Set frame-ancestors in CSP on every page — it does NOT fall back to default-src
  2. 2Use both X-Frame-Options and CSP frame-ancestors for backward compatibility — frame-ancestors takes priority
  3. 3Always validate event.origin before processing postMessage events — never trust messages from unchecked origins
  4. 4Specify the exact target origin when calling postMessage — never use * with sensitive data
  5. 5Never combine allow-scripts and allow-same-origin in sandbox for untrusted content — together they allow sandbox escape
  6. 6Always verify postMessage claims server-side — the client-side message is untrusted even from validated origins