Clickjacking: Frame-Busting and X-Frame-Options

Clickjacking is a UI redress attack where an attacker embeds your legitimate website inside an invisible iframe on their malicious page. They position the iframe so that when users think they're...

Key Insights

  • JavaScript frame-busting is fundamentally broken—attackers can bypass it with the sandbox attribute, disabled JavaScript, or double-framing techniques, making HTTP headers your only reliable defense.
  • Use both X-Frame-Options and Content-Security-Policy: frame-ancestors together for maximum compatibility; the CSP directive is more powerful but X-Frame-Options still catches older browsers.
  • Clickjacking protection requires defense-in-depth: combine HTTP headers as your primary layer, JavaScript as a fallback for legacy scenarios, and SameSite cookies to prevent cross-origin request contexts.

What is Clickjacking?

Clickjacking is a UI redress attack where an attacker embeds your legitimate website inside an invisible iframe on their malicious page. They position the iframe so that when users think they’re clicking on something innocent—a button, a link, a game—they’re actually clicking on sensitive controls in your hidden application.

The attack is devastatingly simple. An attacker creates a page that looks like a game or a tempting offer. Underneath, invisible to the user, sits your banking application’s “Transfer Funds” button or your social media’s “Delete Account” confirmation. One click, and the user has performed an action they never intended.

Real-world impact has been severe. Attackers have used clickjacking to hijack Facebook likes, steal OAuth tokens, change router DNS settings, and even enable webcams through Flash permission dialogs. Any action a logged-in user can perform with a single click is vulnerable.

Here’s how trivially an attacker sets up the attack:

<!DOCTYPE html>
<html>
<head>
  <title>Win a Free iPhone!</title>
  <style>
    .decoy {
      position: absolute;
      top: 100px;
      left: 100px;
      z-index: 1;
    }
    
    .hidden-target {
      position: absolute;
      top: 85px;  /* Positioned so button aligns with decoy */
      left: 80px;
      opacity: 0.0001;  /* Nearly invisible but still clickable */
      z-index: 2;
    }
  </style>
</head>
<body>
  <button class="decoy">Click Here to Claim Your Prize!</button>
  
  <iframe 
    class="hidden-target"
    src="https://yourbank.com/transfer?amount=1000&to=attacker"
    width="500"
    height="300">
  </iframe>
</body>
</html>

The user sees only the “Claim Your Prize” button. The iframe containing your application floats invisibly on top, perfectly aligned so the user clicks your real button instead.

Classic Frame-Busting Techniques

Before HTTP headers offered a solution, developers relied on JavaScript to detect and escape frames. The simplest approach checks if the current window is the top-level window:

// Basic frame-buster (don't rely on this alone)
if (window.top !== window.self) {
  window.top.location = window.self.location;
}

This works against naive attacks but fails spectacularly against determined attackers. Developers evolved more aggressive patterns:

// "Aggressive" frame-buster with continuous checking
(function() {
  if (window.top !== window.self) {
    try {
      // Attempt to bust out
      window.top.location.replace(window.self.location.href);
    } catch (e) {
      // Cross-origin frame, can't access top.location
      // Force the page to be unusable
      document.body.innerHTML = '';
      document.body.style.display = 'none';
    }
  }
  
  // Keep checking in case of race conditions
  setInterval(function() {
    if (window.top !== window.self) {
      window.top.location.replace(window.self.location.href);
    }
  }, 100);
})();

Some implementations added visual countermeasures, hiding content until JavaScript confirmed the page wasn’t framed:

<style>
  body { display: none !important; }
</style>

<script>
  if (window.self === window.top) {
    document.body.style.display = 'block';
  }
</script>

Why Frame-Busting Falls Short

Every JavaScript frame-buster has fatal flaws. Attackers discovered that the HTML5 sandbox attribute on iframes disables JavaScript in the framed content while keeping it interactive:

<!-- Attacker's page: sandbox disables the victim's JavaScript -->
<iframe 
  sandbox="allow-forms allow-same-origin"
  src="https://victim.com/sensitive-action">
</iframe>

The sandbox attribute without allow-scripts prevents any JavaScript from running in the framed page. Your frame-buster never executes. The user can still click buttons and submit forms—exactly what the attacker wants.

Double-framing defeats frame-busters that use onbeforeunload handlers to prevent navigation:

<!-- Outer attacker page -->
<iframe src="middle-frame.html"></iframe>

<!-- middle-frame.html -->
<script>
  // Trap navigation attempts from the victim frame
  window.onbeforeunload = function() {
    return false;
  };
</script>
<iframe src="https://victim.com"></iframe>

The middle frame catches and blocks the victim’s attempt to navigate top.location. Browser inconsistencies make this worse—what works in Chrome might fail in Firefox, and vice versa.

Even without these attacks, users who disable JavaScript for security or accessibility reasons get no protection at all. JavaScript-only defenses are fundamentally the wrong layer for this problem.

X-Frame-Options Header

The real solution operates at the HTTP level, where attackers can’t interfere. The X-Frame-Options header tells browsers whether to allow your page in a frame:

X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN

DENY prevents all framing. SAMEORIGIN allows framing only by pages from the same origin. There was an ALLOW-FROM directive, but browsers never implemented it consistently—don’t use it.

Here’s how to set the header in common environments:

// Express.js middleware
app.use((req, res, next) => {
  res.setHeader('X-Frame-Options', 'DENY');
  next();
});

// Or use helmet for comprehensive security headers
const helmet = require('helmet');
app.use(helmet.frameguard({ action: 'deny' }));
# Nginx configuration
server {
    add_header X-Frame-Options "SAMEORIGIN" always;
    
    # For complete denial:
    # add_header X-Frame-Options "DENY" always;
}
# Apache .htaccess or httpd.conf
Header always set X-Frame-Options "DENY"

# Or for same-origin only:
Header always set X-Frame-Options "SAMEORIGIN"

The browser enforces this before rendering the page. No JavaScript runs, no race conditions exist, and the attacker’s sandbox attribute is irrelevant—the browser simply refuses to display your content in a frame.

Content-Security-Policy: frame-ancestors

The X-Frame-Options header works but has limitations. It can’t specify multiple allowed origins or use wildcards. The Content Security Policy frame-ancestors directive provides modern, flexible control:

Content-Security-Policy: frame-ancestors 'none';
Content-Security-Policy: frame-ancestors 'self';
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com;
Content-Security-Policy: frame-ancestors https://*.mycompany.com;

The frame-ancestors directive supersedes X-Frame-Options when both are present in browsers that support CSP Level 2. Here’s the equivalence:

X-Frame-Options: DENY
# Equivalent to:
Content-Security-Policy: frame-ancestors 'none';

X-Frame-Options: SAMEORIGIN  
# Equivalent to:
Content-Security-Policy: frame-ancestors 'self';

But CSP goes further. You can allow specific external domains to frame your content—useful for legitimate embedding scenarios:

// Express.js with granular frame-ancestors
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "frame-ancestors 'self' https://partner.example.com https://embed.trusted.org"
  );
  next();
});

One critical note: frame-ancestors cannot be set via a <meta> tag. It must be an HTTP header. This is by design—if attackers could inject HTML, they could override the policy.

Defense-in-Depth Implementation

Production applications should layer multiple defenses. HTTP headers handle modern browsers, JavaScript catches edge cases, and SameSite cookies add another barrier:

// Complete clickjacking protection middleware for Express
function clickjackingProtection(options = {}) {
  const { 
    allowedOrigins = [], 
    enableJavaScriptFallback = true 
  } = options;

  return (req, res, next) => {
    // Primary defense: X-Frame-Options for legacy browsers
    if (allowedOrigins.length === 0) {
      res.setHeader('X-Frame-Options', 'DENY');
    } else {
      res.setHeader('X-Frame-Options', 'SAMEORIGIN');
    }

    // Modern defense: CSP frame-ancestors
    let frameAncestors = "'none'";
    if (allowedOrigins.length > 0) {
      frameAncestors = `'self' ${allowedOrigins.join(' ')}`;
    }
    
    // Append to existing CSP or create new one
    const existingCSP = res.getHeader('Content-Security-Policy') || '';
    const newCSP = existingCSP 
      ? `${existingCSP}; frame-ancestors ${frameAncestors}`
      : `frame-ancestors ${frameAncestors}`;
    res.setHeader('Content-Security-Policy', newCSP);

    // Complementary: SameSite cookies prevent CSRF in framed contexts
    // (Set this on your session cookies, not as a header here)

    // Optional: Inject JavaScript fallback for ancient browsers
    if (enableJavaScriptFallback) {
      res.locals.frameGuardScript = `
        <style>html{display:none;}</style>
        <script>
          if(self===top){document.documentElement.style.display='block';}
          else{top.location=self.location;}
        </script>
      `;
    }

    next();
  };
}

// Usage
app.use(clickjackingProtection({
  allowedOrigins: ['https://trusted-embed.example.com'],
  enableJavaScriptFallback: true
}));

For cookies, ensure your session cookies use SameSite=Lax or SameSite=Strict:

// Express session with SameSite cookies
app.use(session({
  cookie: {
    sameSite: 'lax',  // or 'strict' for maximum protection
    secure: true,
    httpOnly: true
  }
}));

Testing and Validation

Verify your protections work. First, check headers in browser DevTools—open the Network tab, select your page request, and inspect the response headers. You should see both X-Frame-Options and Content-Security-Policy with appropriate values.

Create a simple test page to verify framing is blocked:

<!DOCTYPE html>
<html>
<head>
  <title>Clickjacking Protection Test</title>
  <style>
    .result { padding: 20px; margin: 10px; border-radius: 5px; }
    .blocked { background: #d4edda; color: #155724; }
    .vulnerable { background: #f8d7da; color: #721c24; }
    iframe { border: 2px solid #333; margin: 10px 0; }
  </style>
</head>
<body>
  <h1>Clickjacking Protection Test</h1>
  <p>If protection is working, the iframe below should be empty or show an error:</p>
  
  <iframe 
    id="test-frame"
    src="https://your-site.com/sensitive-page"
    width="600"
    height="400">
  </iframe>
  
  <div id="result" class="result"></div>
  
  <script>
    const iframe = document.getElementById('test-frame');
    const result = document.getElementById('result');
    
    iframe.onload = function() {
      try {
        // Try to access iframe content (will fail if blocked or cross-origin)
        const doc = iframe.contentDocument || iframe.contentWindow.document;
        if (doc.body.innerHTML.length > 0) {
          result.className = 'result vulnerable';
          result.textContent = '⚠️ WARNING: Page loaded in iframe - may be vulnerable!';
        }
      } catch (e) {
        result.className = 'result blocked';
        result.textContent = '✓ Protected: Cannot access framed content (expected behavior)';
      }
    };
    
    iframe.onerror = function() {
      result.className = 'result blocked';
      result.textContent = '✓ Protected: Frame refused to load';
    };
  </script>
</body>
</html>

For automated testing, security scanners like OWASP ZAP and Burp Suite check for missing clickjacking headers. Add header validation to your CI pipeline:

# Simple curl check for CI
HEADERS=$(curl -sI https://your-site.com/sensitive-page)

if echo "$HEADERS" | grep -qi "x-frame-options: deny\|x-frame-options: sameorigin"; then
  echo "✓ X-Frame-Options header present"
else
  echo "✗ Missing X-Frame-Options header"
  exit 1
fi

if echo "$HEADERS" | grep -qi "frame-ancestors"; then
  echo "✓ CSP frame-ancestors directive present"
else
  echo "✗ Missing CSP frame-ancestors"
  exit 1
fi

Clickjacking protection is straightforward to implement correctly. Use HTTP headers, test that they’re present, and stop worrying about JavaScript frame-busters. The browser will do the enforcement for you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.