Clickjacking

UI Redress AttackFramejacking

Clickjacking at a glance

What it is: Your pages can be embedded in an iframe by another site, so attackers overlay UI to trick users into clicking sensitive actions.
Why it happens: Clickjacking vulnerabilities occur when frame protections like X-Frame-Options or CSP frame-ancestors are missing, misconfigured, or stripped by intermediaries, allowing malicious framing of site content.
How to fix: Use CSP frame-ancestors with X-Frame-Options as fallback, default to DENY globally, whitelist only trusted parents, and avoid relying solely on JavaScript frame-busters.

Overview

Clickjacking happens when your web pages can be framed by another site. An attacker places your page in a transparent or disguised iframe and aligns sensitive buttons under visible decoys, so the victim clicks on your UI while thinking they click something else. Without anti-framing headers, browsers happily render your page inside an attacker frame.

sequenceDiagram participant Browser participant App as App Server participant AttackerSite as Attacker Site Browser->>AttackerSite: Visit attacker.com AttackerSite-->>Browser: Serve page with <iframe src="https://app.example.com/settings"> Browser->>App: GET /settings App-->>Browser: 200 OK (no frame protections) note over Browser: App renders inside iframe, decoy overlaid Browser->>App: POST /settings/delete App-->>Browser: 200 account deleted
A potential flow for a Clickjacking exploit

Where it occurs

It occurs when framing protections like X-Frame-Options or CSP frame-ancestors are missing, misconfigured, or stripped by intermediaries, exposing pages to clickjacking risks.

Impact

Victims may trigger sensitive actions such as approving payments, changing email or MFA settings, or granting OAuth scopes. The click originates from the victim, so logs show a legitimate user action, which complicates detection and response.

Prevention

Prevent clickjacking by setting strict CSP frame-ancestors and X-Frame-Options headers, limiting embedding to approved origins only, avoiding JS frame-busters, and ensuring proxies or CDNs consistently preserve these headers.

Examples

Switch tabs to view language/framework variants.

Express, sensitive route is frameable (no X-Frame-Options or CSP)

Server returns account settings without any anti-framing headers.

Vulnerable
JavaScript • Express — Bad
const express = require('express');
const app = express();
app.get('/settings', (req, res) => {
  res.send('<button id="delete">Delete account</button>');
});
  • Line 3: No anti-framing headers on a sensitive page

Without anti-framing headers, attackers can overlay your page in an iframe and capture clicks.

Secure
JavaScript • Express — Good
const express = require('express');
const app = express();
app.use((req,res,next)=>{
  res.setHeader('Content-Security-Policy','frame-ancestors \"none\"');
  res.setHeader('X-Frame-Options','DENY');
  next();
});
app.get('/settings',(req,res)=>{
  res.send('<button id="delete">Delete account</button>');
});
  • Line 4: CSP frame-ancestors 'none' prevents all framing
  • Line 5: X-Frame-Options DENY as a belt-and-braces fallback

Send CSP frame-ancestors (preferred) and optionally X-Frame-Options to block framing.

Engineer Checklist

  • Set CSP frame-ancestors globally to 'none' or a tight allow list

  • Keep X-Frame-Options DENY or SAMEORIGIN as fallback

  • Apply per-route CSP allow list only for approved embed pages

  • Verify headers are preserved by proxies/CDNs and on error pages

  • Avoid JavaScript frame-busters as the only protection

End-to-End Example

A finance dashboard leaves /settings frameable. An attacker hosts a page with an invisible iframe of /settings and aligns a large 'See more' button over the real 'Delete account' button.

Vulnerable
JAVASCRIPT
// Node.js/Express - Vulnerable to clickjacking

// VULNERABLE: No frame protection headers
app.get('/settings', (req, res) => {
  // VULNERABLE: Missing X-Frame-Options and CSP frame-ancestors
  // Page can be embedded in attacker's iframe
  
  res.send(`
    <html>
      <body>
        <h1>Account Settings</h1>
        <button id="delete" onclick="deleteAccount()">Delete Account</button>
        <button id="transfer" onclick="transferMoney()">Transfer $1000</button>
        <button id="changeEmail" onclick="changeEmail()">Change Email</button>
      </body>
    </html>
  `);
});

// VULNERABLE: Payment page without frame protection
app.get('/payment', (req, res) => {
  // VULNERABLE: Attacker can iframe this page and overlay buttons
  // User thinks they're clicking "See cute cat" but actually clicking "Approve Payment"
  
  res.send(`
    <html>
      <body>
        <h1>Confirm Payment</h1>
        <form action="/payment/confirm" method="POST">
          <p>Amount: $5000</p>
          <p>To: Vendor Inc</p>
          <button type="submit">Approve Payment</button>
          <button type="button">Cancel</button>
        </form>
      </body>
    </html>
  `);
});

// VULNERABLE: OAuth consent page
app.get('/oauth/authorize', (req, res) => {
  const { client_id, scope } = req.query;
  
  // VULNERABLE: OAuth consent can be framed
  // Attacker overlays UI to trick user into granting permissions
  
  res.send(`
    <html>
      <body>
        <h1>Grant Access?</h1>
        <p>App "${client_id}" requests access to:</p>
        <ul>
          <li>Read your profile</li>
          <li>Access your contacts</li>
          <li>Post on your behalf</li>
        </ul>
        <form action="/oauth/authorize" method="POST">
          <button type="submit" name="action" value="allow">Allow</button>
          <button type="submit" name="action" value="deny">Deny</button>
        </form>
      </body>
    </html>
  `);
});

// VULNERABLE: Admin panel without protection
app.get('/admin/users', requireAdmin, (req, res) => {
  // VULNERABLE: Even admin pages can be framed!
  // Attacker tricks admin into clicking "Delete All Users"
  
  res.send(`
    <html>
      <body>
        <h1>User Management</h1>
        <button onclick="deleteUser(123)">Delete User</button>
        <button onclick="makeAdmin(456)">Promote to Admin</button>
        <button onclick="disableMFA(789)">Disable MFA</button>
      </body>
    </html>
  `);
});

// VULNERABLE: JavaScript frame-buster (easily bypassed)
app.get('/legacy', (req, res) => {
  // VULNERABLE: JavaScript frame-buster is NOT sufficient!
  // Can be bypassed with sandbox attribute: <iframe sandbox="allow-forms allow-scripts">
  
  res.send(`
    <html>
      <head>
        <script>
          // VULNERABLE: Easily bypassed!
          if (top !== self) {
            top.location = self.location;
          }
        </script>
      </head>
      <body>
        <h1>"Protected" Page</h1>
        <button onclick="sensitiveAction()">Important Action</button>
      </body>
    </html>
  `);
});

// VULNERABLE: Global middleware disabled for one route
app.use((req, res, next) => {
  // Set frame protection globally
  res.setHeader('X-Frame-Options', 'DENY');
  next();
});

// But then:
app.get('/widget', (req, res) => {
  // VULNERABLE: Removes protection for widget
  // But forgets ALL other pages are now frameable!
  res.removeHeader('X-Frame-Options');
  
  res.send('<html><body>Embeddable widget</body></html>');
});
Secure
JAVASCRIPT
// Node.js/Express - Protected against clickjacking

const helmet = require('helmet');

// SECURE: Global frame protection middleware
app.use(helmet({
  frameguard: { action: 'deny' },  // Sets X-Frame-Options: DENY
  contentSecurityPolicy: {
    directives: {
      frameAncestors: ["'none'"],  // Modern CSP protection
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
    }
  }
}));

// SECURE: Settings page with frame protection
app.get('/settings', (req, res) => {
  // Headers already set by middleware
  // X-Frame-Options: DENY
  // Content-Security-Policy: frame-ancestors 'none'
  
  res.send(`
    <html>
      <body>
        <h1>Account Settings</h1>
        <button id="delete" onclick="deleteAccount()">Delete Account</button>
        <button id="transfer" onclick="transferMoney()">Transfer $1000</button>
        <button id="changeEmail" onclick="changeEmail()">Change Email</button>
      </body>
    </html>
  `);
});

// SECURE: Payment page with strict protection
app.get('/payment', (req, res) => {
  // Ensure frame protection for sensitive actions
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
  
  res.send(`
    <html>
      <body>
        <h1>Confirm Payment</h1>
        <form action="/payment/confirm" method="POST">
          <p>Amount: $5000</p>
          <p>To: Vendor Inc</p>
          <button type="submit">Approve Payment</button>
          <button type="button">Cancel</button>
        </form>
      </body>
    </html>
  `);
});

// SECURE: OAuth consent with frame protection
app.get('/oauth/authorize', (req, res) => {
  const { client_id, scope } = req.query;
  
  // SECURE: Prevent clickjacking on OAuth consent
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
  
  res.send(`
    <html>
      <body>
        <h1>Grant Access?</h1>
        <p>App "${client_id}" requests access to:</p>
        <ul>
          <li>Read your profile</li>
          <li>Access your contacts</li>
          <li>Post on your behalf</li>
        </ul>
        <form action="/oauth/authorize" method="POST">
          <button type="submit" name="action" value="allow">Allow</button>
          <button type="submit" name="action" value="deny">Deny</button>
        </form>
      </body>
    </html>
  `);
});

// SECURE: Admin panel with protection
app.get('/admin/users', requireAdmin, (req, res) => {
  // Extra protection for admin pages
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
  
  res.send(`
    <html>
      <body>
        <h1>User Management</h1>
        <button onclick="deleteUser(123)">Delete User</button>
        <button onclick="makeAdmin(456)">Promote to Admin</button>
        <button onclick="disableMFA(789)">Disable MFA</button>
      </body>
    </html>
  `);
});

// SECURE: Intentionally embeddable widget with specific allowlist
app.get('/widget', (req, res) => {
  // SECURE: Only allow specific trusted domains to frame this widget
  const ALLOWED_PARENTS = [
    'https://partner1.example.com',
    'https://partner2.example.com'
  ];
  
  // Use CSP frame-ancestors with explicit allowlist
  res.setHeader(
    'Content-Security-Policy',
    `frame-ancestors ${ALLOWED_PARENTS.join(' ')}`
  );
  
  // X-Frame-Options doesn't support allowlist, so omit or use SAMEORIGIN
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
  
  res.send('<html><body>Embeddable widget (only for trusted partners)</body></html>');
});

// SECURE: Middleware to verify headers are set
app.use((req, res, next) => {
  const originalSend = res.send;
  
  res.send = function(data) {
    // Verify frame protection headers are present on responses
    const hasFrameProtection = 
      res.getHeader('X-Frame-Options') || 
      (res.getHeader('Content-Security-Policy') || '').includes('frame-ancestors');
    
    if (!hasFrameProtection && req.path !== '/widget') {
      console.warn('Missing frame protection on:', req.path);
    }
    
    return originalSend.call(this, data);
  };
  
  next();
});

// SECURE: Nginx configuration
/*
server {
    listen 443 ssl http2;
    server_name app.example.com;
    
    # Global frame protection
    add_header X-Frame-Options "DENY" always;
    add_header Content-Security-Policy "frame-ancestors 'none'" always;
    
    # Ensure headers are sent even on error pages
    add_header X-Frame-Options "DENY" always;
    
    location / {
        proxy_pass http://backend:3000;
        
        # Don't let backend override security headers
        proxy_hide_header X-Frame-Options;
        proxy_hide_header Content-Security-Policy;
    }
    
    # Specific route for embeddable widget
    location /widget {
        add_header Content-Security-Policy "frame-ancestors https://partner1.example.com https://partner2.example.com" always;
        proxy_pass http://backend:3000;
    }
}
*/

// SECURE: Error pages also protected
app.use((err, req, res, next) => {
  // Ensure error pages can't be framed either
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
  
  res.status(500).send('Internal Server Error');
});

Discovery

This vulnerability is discovered by inspecting HTTP response headers for missing or misconfigured anti-framing protections (X-Frame-Options and CSP frame-ancestors), then testing if the application can be embedded in an attacker-controlled iframe.

  1. 1. Inspect response headers

    http

    Action

    Request the /settings page and examine HTTP response headers for anti-framing protections

    Request

    GET https://app.example.com/settings

    Response

    Status: 200
    Body:
    {
      "note": "Headers show no X-Frame-Options or Content-Security-Policy frame-ancestors directives present in the response"
    }

    Artifacts

    http_response_headers http_status
  2. 2. Test iframe embedding

    browser

    Action

    Create a test HTML page that embeds /settings in an iframe to confirm it renders

    Request

    ANALYSIS N/A - Analysis step

    Response

    Status: 200
    Body:
    {
      "note": "The /settings page successfully renders inside the iframe without any browser blocking or warnings, confirming frameability"
    }

    Artifacts

    test_iframe_html browser_console_log
  3. 3. Identify sensitive actions

    analysis

    Action

    Analyze the /settings page to identify state-changing buttons like 'Delete account', 'Disable MFA', or 'Change email'

    Request

    ANALYSIS N/A - Analysis step

    Response

    Status: 200
    Body:
    {
      "note": "Page contains multiple high-risk actions including account deletion, email change, and security setting modifications, all accessible via clickable buttons"
    }

    Artifacts

    page_action_inventory dom_analysis
  4. 4. Check additional sensitive pages

    http

    Action

    Test other critical endpoints like /transfer, /admin, /oauth/authorize for frame protections

    Request

    GET https://app.example.com/transfer

    Response

    Status: 200
    Body:
    {
      "note": "Multiple sensitive pages lack frame protection headers, expanding the attack surface beyond just settings"
    }

    Artifacts

    vulnerable_endpoints_list

Exploit steps

An attacker exploits this by embedding the vulnerable page in a transparent or disguised iframe on an attacker-controlled site, overlaying deceptive UI elements to trick users into clicking sensitive buttons or links they didn't intend to interact with.

  1. 1. Craft malicious overlay page

    Build clickjacking exploit

    analysis

    Action

    Create HTML page that iframes /settings and uses CSS to make it transparent, positioning a deceptive button over the 'Delete account' button

    Request

    ANALYSIS N/A - Analysis step

    Response

    Status: 200
    Body:
    {
      "note": "HTML exploit page created with iframe positioned absolutely with opacity: 0.0001, overlaid by visible 'Click here to claim your prize!' button at exact coordinates"
    }

    Artifacts

    exploit_html css_overlay_styles
  2. 2. Social engineer victim visit

    Lure victim to malicious page

    analysis

    Action

    Send phishing email or social media message with link to attacker-controlled page, using enticing messaging like prize claims or urgent notifications

    Request

    ANALYSIS N/A - Analysis step

    Response

    Status: 200
    Body:
    {
      "note": "Victim clicks link and visits https://attacker.example/overlay, loading the malicious page while still authenticated to app.example.com"
    }

    Artifacts

    phishing_message visitor_log
  3. 3. Execute hidden action via victim click

    Trigger framed button click

    http

    Action

    Victim clicks the visible decoy button, which actually clicks the underlying 'Delete account' button in the transparent iframe

    Request

    POST https://app.example.com/settings/delete
    Headers:
    Cookie: <VICTIM_SESSION>

    Response

    Status: 200
    Body:
    {
      "note": "Victim's click triggers account deletion request using their authenticated session, returning 200 OK with account deletion confirmation"
    }

    Artifacts

    http_response_body account_deletion_log
  4. 4. Confirm exploitation success

    Verify account destruction

    analysis

    Action

    Victim realizes their account has been deleted without their intention, demonstrating successful clickjacking attack

    Request

    ANALYSIS N/A - Analysis step

    Response

    Status: 200
    Body:
    {
      "note": "Victim account is permanently deleted, all data lost, and user is logged out with no way to recover access"
    }

    Artifacts

    victim_complaint support_ticket

Specific Impact

Customers unintentionally delete accounts and disable security features. Support sees unexplained destructive actions tied to legitimate sessions, which complicates incident response.

The attacker can chain the attack with CSRF-resistant flows by harvesting clicks directly, because the victim is genuinely clicking the real button.

Fix

Set CSP frame-ancestors to block embedding. Keep XFO as a fallback. Allow embedding only on specific routes with an explicit parent allow list. Validate that headers are present through all layers and on error responses.

Detect This Vulnerability in Your Code

Sourcery automatically identifies clickjacking vulnerabilities and many other security issues in your codebase.

Scan Your Code for Free