Clickjacking Prevention: X-Frame-Options, CSP frame-ancestors, and SameSite

Clickjacking lets attackers trick users into clicking hidden page elements by overlaying transparent iframes on legitimate content. This guide covers the three main defences — X-Frame-Options, CSP frame-ancestors, and SameSite cookies — with implementation examples in Node.js, Python, and Java.

Clickjacking — also called UI redressing — is an attack where an attacker embeds your website in a transparent or hidden iframe on their own page, then overlays fake UI elements over the visible portion. When the victim clicks what looks like a benign button on the attacker’s page, they’re actually clicking a button on your site: a funds transfer confirmation, a permission grant, an account deletion, an OAuth authorisation.

The attack is simple to execute, often invisible to the user, and routinely underestimated in threat models. OWASP listed it in its Top 10 for years; it remains a common finding in application security assessments.

The good news: preventing clickjacking is a headers problem, and it’s largely solved by two directives that take minutes to implement correctly.

How the Attack Works

The core technique:

<!-- attacker's page -->
<html>
<head>
  <style>
    iframe {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      opacity: 0;  /* invisible to user */
      z-index: 2;
    }
    .fake-button {
      position: absolute;
      top: 200px;
      left: 300px;
      z-index: 1;
      /* positioned exactly over the target action on the framed site */
    }
  </style>
</head>
<body>
  <iframe src="https://victim-bank.com/transfer?amount=1000&to=attacker"></iframe>
  <div class="fake-button">Click here to claim your prize!</div>
</body>
</html>

The user clicks “claim your prize.” What actually receives the click is the confirmation button on the victim bank’s transfer page, loaded invisibly in the overlaid iframe. If the user is logged into the bank, the transfer executes.

Clickjacking attacks typically target high-value actions: payment confirmation, social media sharing/following, permission granting (especially OAuth), account settings changes, and one-click actions in web apps.

Defence 1: X-Frame-Options Header

X-Frame-Options is an HTTP response header that tells browsers whether your page may be framed. It’s supported by all modern browsers and takes three values:

  • DENY — the page cannot be displayed in any frame, regardless of origin
  • SAMEORIGIN — the page can only be framed by pages on the same origin
  • ALLOW-FROM <uri> — deprecated; not supported in most modern browsers

For most applications, DENY is the correct choice. SAMEORIGIN is appropriate if you have legitimate same-origin framing (e.g., your app frames pages from its own domain in a dashboard widget).

Node.js (Express):

const express = require('express');
const app = express();

// Set for all responses
app.use((req, res, next) => {
  res.setHeader('X-Frame-Options', 'DENY');
  next();
});

// Or use Helmet, which sets it by default
const helmet = require('helmet');
app.use(helmet()); // includes X-Frame-Options: SAMEORIGIN by default

// Override Helmet's default to DENY
app.use(helmet.frameguard({ action: 'deny' }));

Python (Django):

Django includes clickjacking protection in its middleware:

# settings.py
MIDDLEWARE = [
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    # ... other middleware
]

# Default value is DENY
X_FRAME_OPTIONS = 'DENY'

# If you need SAMEORIGIN for specific views, use the decorator:
# from django.views.decorators.clickjacking import xframe_options_sameorigin

Python (Flask):

from flask import Flask, after_this_request, g

app = Flask(__name__)

@app.after_request
def add_security_headers(response):
    response.headers['X-Frame-Options'] = 'DENY'
    return response

Java (Spring Security):

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .frameOptions(frameOptions -> frameOptions.deny())
            );
        return http.build();
    }
}

Go (net/http):

func securityHeadersMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Frame-Options", "DENY")
        next.ServeHTTP(w, r)
    })
}

Defence 2: Content-Security-Policy frame-ancestors

Content-Security-Policy: frame-ancestors is the modern successor to X-Frame-Options and should be set alongside it for defence-in-depth. It’s more flexible and the CSP spec is the forward path.

The directive specifies which origins may embed your page in a frame, iframe, object, or embed element.

# Block all framing
Content-Security-Policy: frame-ancestors 'none';

# Allow framing only from same origin
Content-Security-Policy: frame-ancestors 'self';

# Allow framing from specific trusted domains
Content-Security-Policy: frame-ancestors 'self' https://dashboard.example.com;

Critical difference from X-Frame-Options: When both headers are present, frame-ancestors takes precedence in browsers that support CSP Level 2 (all modern browsers). Set both for compatibility with older browsers.

Node.js (Express with Helmet):

const helmet = require('helmet');

app.use(
  helmet.contentSecurityPolicy({
    directives: {
      frameAncestors: ["'none'"],
      // ... other CSP directives
    },
  })
);

Nginx configuration:

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

Apache configuration:

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

Defence 3: SameSite Cookies

SameSite cookie attributes prevent your session cookies from being sent when your site is loaded in a third-party context (which includes attacker-controlled iframes). This doesn’t prevent the framing itself but breaks the authentication state that makes clickjacking useful.

Set-Cookie: sessionid=abc123; SameSite=Strict; Secure; HttpOnly
  • SameSite=Strict — cookies are never sent in cross-site requests. Most secure; may break legitimate cross-site flows (e.g., arriving at your site from an external link with an active session).
  • SameSite=Lax — cookies sent for top-level navigation GET requests, not for embedded resources or POSTs from other origins. A good default for most session cookies.
  • SameSite=None — must be accompanied by Secure. Required for cross-site cookies (e.g., third-party widgets that need to maintain state).

Express.js session configuration:

const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,
    secure: true,         // HTTPS only
    sameSite: 'strict',   // No cross-site sending
    maxAge: 1800000       // 30 minutes
  }
}));

Django:

# settings.py
SESSION_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = 'Strict'

Common Mistakes

Setting headers only on HTML pages, not API endpoints. Clickjacking that targets JSON-returning endpoints is less common but possible in some attack scenarios. Setting security headers globally via middleware is the correct approach.

Using ALLOW-FROM. This directive is not supported in Chrome, Firefox, or Safari. If you need to permit specific third-party framing, use CSP: frame-ancestors <specific-origin> instead.

Relying solely on JavaScript frame-busting. Frame-busting JavaScript (checking window.top !== window.self) was the pre-header approach and is bypassable. Attackers can use the sandbox iframe attribute to prevent JavaScript execution in the framed content, or load the iframe from a domain that sandboxes it. Never rely on JavaScript as the primary clickjacking defence.

Not testing the header on subdomains. If your main app sets X-Frame-Options: SAMEORIGIN and a subdomain embeds it, the framing is permitted. If that subdomain is compromised or itself embeds attacker content, you have a clickjacking path. Use DENY where no legitimate framing is needed.

Verifying Your Implementation

Quick verification from the command line:

# Check headers on your application
curl -sI https://yourapp.example.com | grep -i -E "x-frame|content-security"

# Expected output:
# x-frame-options: DENY
# content-security-policy: frame-ancestors 'none'; ...

Browser developer tools also show response headers clearly in the Network tab. Run a PageSpeed or Mozilla Observatory scan to confirm the headers are set correctly before marking this as resolved.