Web Security

Clickjacking Prevention: X-Frame-Options vs frame-ancestors CSP

Understand clickjacking and UI redressing attacks, and learn how to implement frame-ancestors CSP and X-Frame-Options headers with browser testing techniques.

March 9, 20267 min readShipSafer Team

Clickjacking Prevention: X-Frame-Options vs frame-ancestors CSP

Clickjacking is a UI manipulation attack in which an attacker embeds your website in a hidden or transparent iframe on a malicious page. The victim sees the attacker's page but unknowingly interacts with your site layered beneath it. A single click on what appears to be an innocuous button on the attacker's page can trigger a funds transfer, account setting change, permission grant, or social media post on your site.

The attack requires no XSS, no stolen credentials, and no malware. The protection is a single HTTP response header — yet clickjacking remains a common finding in security audits because many applications still do not set it correctly.

How Clickjacking Works

The attacker builds a page with a transparent iframe pointing to your site:

<!DOCTYPE html>
<html>
<head>
  <style>
    iframe {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      opacity: 0.0;  /* completely invisible */
      z-index: 999;
    }
    .decoy-button {
      position: absolute;
      top: 300px;
      left: 200px;
      z-index: 1;
    }
  </style>
</head>
<body>
  <button class="decoy-button">Click here to claim your prize!</button>
  <iframe src="https://bank.example.com/transfer?to=attacker&amount=500"></iframe>
</body>
</html>

The iframe is positioned so that the "Confirm Transfer" button in your application sits directly under the attacker's "Click here to claim your prize!" button. When the victim clicks the decoy, they are actually clicking the confirm button in your application.

Because the victim is already logged into your site (session cookie is sent automatically), the transfer executes with their full authorization.

UI Redressing Variants

Clickjacking extends beyond simple button clicks:

Drag-and-drop attacks: An attacker overlays a drag target on a sensitive UI element. The user thinks they are dragging a game piece but is actually dragging their profile photo to the attacker's element, triggering a file read.

Likejacking: A transparent Facebook or Twitter "Like"/"Follow" button is overlaid on an attractive image. The user clicks the image and unknowingly follows the attacker's page.

Cursor spoofing: CSS can change how the cursor looks, making the user believe they are clicking in one location while the actual click registers elsewhere.

Multi-step attacks: Some operations require multiple clicks (e.g., "Settings" then "Delete Account" then "Confirm"). Attackers build multi-page overlays that walk the user through the sequence.

Keystroke capture: iframes combined with text fields positioned under visible form fields can capture keystrokes — the user types into what looks like a search box but is actually a hidden field on the framed site.

X-Frame-Options

X-Frame-Options is the older header for clickjacking prevention, supported by all browsers. It accepts three values:

  • DENY — the page cannot be framed by any domain, including the same origin
  • SAMEORIGIN — the page can only be framed by pages on the same origin
  • ALLOW-FROM https://trusted.example.com — the page can only be framed by the specified origin (deprecated, not supported in modern browsers)
X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN

DENY is the safest choice for applications that have no legitimate need to be embedded in iframes. Use SAMEORIGIN if your application uses iframes internally (e.g., an embedded dashboard within the same domain).

The ALLOW-FROM directive is obsolete. Modern browsers ignore it. If you need to allow framing by a specific third-party origin, use frame-ancestors CSP instead.

frame-ancestors CSP: The Modern Standard

The Content-Security-Policy: frame-ancestors directive is the modern replacement for X-Frame-Options. It is more flexible, supports multiple origins, supports wildcards in origin patterns, and takes precedence over X-Frame-Options in browsers that support both.

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

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

# Allow framing by specific trusted origins (no ALLOW-FROM equivalent exists in X-Frame-Options)
Content-Security-Policy: frame-ancestors 'self' https://dashboard.trusted.com https://embed.partner.com

frame-ancestors accepts the same source list syntax as other CSP directives, giving you fine-grained control over which origins can embed your pages.

Implementation Guide

Next.js

Set both headers in next.config.ts for defense in depth. Older browsers use X-Frame-Options; modern browsers use frame-ancestors:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'Content-Security-Policy',
            value: "frame-ancestors 'none'",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

If you use a full CSP header (which you should), add frame-ancestors to the existing policy rather than setting a separate CSP header — browsers apply only one CSP per response:

const cspHeader = [
  "default-src 'self'",
  "script-src 'self' 'nonce-{NONCE}'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https:",
  "frame-ancestors 'none'",   // add here
  "base-uri 'self'",
  "form-action 'self'",
].join('; ');

Express / Node.js

import helmet from 'helmet';
import express from 'express';

const app = express();

app.use(helmet.frameguard({ action: 'deny' }));

// Or with full helmet configuration
app.use(helmet({
  frameguard: { action: 'deny' },
  contentSecurityPolicy: {
    directives: {
      frameAncestors: ["'none'"],
      defaultSrc: ["'self'"],
      // ... other directives
    },
  },
}));

nginx

server {
  add_header X-Frame-Options "DENY" always;
  add_header Content-Security-Policy "frame-ancestors 'none'" always;
}

The always flag ensures the headers are included in error responses as well as successful responses. Without always, nginx omits these headers on 4xx and 5xx responses — and error pages are still frameable.

Apache

Header always set X-Frame-Options "DENY"
Header always set Content-Security-Policy "frame-ancestors 'none'"

Choosing Between DENY and SAMEORIGIN

Use DENY (and frame-ancestors 'none') for:

  • Login pages, payment pages, account settings, data export, destructive actions
  • Any page where a user might perform an action triggered by a click
  • Applications where there is no legitimate use case for embedding

Use SAMEORIGIN (and frame-ancestors 'self') for:

  • Applications that use iframes within the same domain (embedded maps, in-app previews)
  • Multi-page SPAs where one origin embeds another route in an iframe

Use specific origins with frame-ancestors for:

  • Embeddable widgets (a customer chat widget, an embeddable booking calendar)
  • Partner portal integrations where a trusted domain must embed your pages
  • Documentation tools that preview your application
# Widget embeddable only by your customers' domains (cannot be wildcarded to 'any origin')
Content-Security-Policy: frame-ancestors 'self' https://app.customerA.com https://portal.customerB.com

Testing Clickjacking Protection

Browser developer tools

Open DevTools in any browser, navigate to the Console tab, and run:

const frame = document.createElement('iframe');
frame.src = 'https://yourapp.example.com/sensitive-page';
document.body.appendChild(frame);

If the browser's console shows a CSP violation or refused frame error, your headers are working. If the iframe loads, the page is vulnerable.

Alternatively, create a simple HTML file locally:

<iframe src="https://yourapp.example.com/dashboard" width="800" height="600"></iframe>

Open it in a browser. A protected page shows a blank iframe or a browser error. An unprotected page renders fully inside the frame.

Automated scanning

Include clickjacking header checks in your security scanning pipeline. Tools like Nikto, OWASP ZAP, and securityheaders.com check for the presence and correctness of X-Frame-Options and frame-ancestors.

ShipSafer's HTTP Security Headers scan checks for both headers across all pages of your application, not just the homepage — many sites set headers on the root path but miss them on internal pages rendered by framework middleware.

Verifying header precedence

When both X-Frame-Options and frame-ancestors are set, frame-ancestors takes precedence in modern browsers. Verify that your frame-ancestors directive reflects your intended policy and is not being overridden by a conflicting X-Frame-Options value set at a different layer (e.g., an upstream proxy setting SAMEORIGIN while your application sets frame-ancestors 'none').

Inspect the actual response headers for a production request using curl -I https://yourapp.example.com/ to see exactly what headers reach the browser.

When Frame Protection Is Not Enough

Clickjacking protection headers prevent your pages from being framed. They do not protect against:

  • Open redirects that can be chained with clickjacking
  • Phishing pages that visually mimic your UI without framing it
  • Compromised same-origin pages that frame your sensitive pages (since SAMEORIGIN allows this)

For sensitive operations like account deletion, password changes, and financial transactions, require re-authentication (confirm password prompt) as a secondary defense. An attacker who tricks a user into clicking a button cannot replay the password confirmation step.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.