Clickjacking Prevention in Python — Deep Dive

Attack mechanics in depth

A sophisticated clickjacking attack uses CSS to make the target iframe transparent and precisely positioned:

<!-- Attacker's page -->
<style>
  .bait { position: relative; z-index: 1; }
  iframe.target {
    position: absolute;
    top: 0; left: 0;
    width: 500px; height: 300px;
    opacity: 0.0001;  /* Nearly invisible but still clickable */
    z-index: 2;
    border: none;
  }
</style>

<div class="bait">
  <h1>Click here to claim your prize!</h1>
  <button>Claim Now</button>
</div>
<iframe class="target" src="https://bank.com/transfer?to=attacker&amount=500"></iframe>

The iframe sits on top of the bait content. The user sees the “Claim Now” button but actually clicks the transfer confirmation button in the invisible bank page. More advanced variants use multiple iframes, drag-and-drop interactions, or cursor manipulation to make the attack harder to detect.

Django’s built-in protection

Django’s XFrameOptionsMiddleware is enabled by default in new projects:

# settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",  # default
    # ...
]

# Default value
X_FRAME_OPTIONS = "DENY"

Per-view control uses decorators:

from django.views.decorators.clickjacking import (
    xframe_options_deny,
    xframe_options_sameorigin,
    xframe_options_exempt,
)

# Completely block framing
@xframe_options_deny
def sensitive_view(request):
    return render(request, "admin/settings.html")

# Allow same-origin framing (for your own iframes)
@xframe_options_sameorigin
def widget_preview(request):
    return render(request, "widgets/preview.html")

# Remove X-Frame-Options for this view (use CSP instead)
@xframe_options_exempt
def embeddable_widget(request):
    response = render(request, "widgets/embed.html")
    response["Content-Security-Policy"] = "frame-ancestors https://partner.com"
    return response

For class-based views, use the XFrameOptionsExemptMixin or apply the decorator in urls.py.

CSP frame-ancestors — the modern approach

Content-Security-Policy: frame-ancestors supersedes X-Frame-Options and is more powerful:

# Django middleware for CSP frame-ancestors
class CSPFrameAncestorsMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        # Default: block all framing
        if not response.has_header("Content-Security-Policy"):
            response["Content-Security-Policy"] = "frame-ancestors 'none'"
        return response

The frame-ancestors directive supports multiple origins, wildcards for subdomains, and scheme restrictions:

# Allow self and all subdomains of partner.com over HTTPS
frame-ancestors 'self' https://*.partner.com

# Allow any HTTPS origin (less secure, but sometimes needed)
frame-ancestors https:

# Block everything
frame-ancestors 'none'

When both X-Frame-Options and frame-ancestors are present, browsers that support CSP ignore X-Frame-Options. Send both headers for backward compatibility with older browsers.

Flask implementation

Flask has no built-in clickjacking protection. Here are three approaches:

Manual header (minimal):

@app.after_request
def add_security_headers(response):
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
    return response

Flask-Talisman (comprehensive):

from flask_talisman import Talisman

talisman = Talisman(
    app,
    frame_options="DENY",
    content_security_policy={
        "frame-ancestors": "'none'",
        "default-src": "'self'",
    },
)

# Per-route override
@app.route("/embed/widget")
@talisman(frame_options="ALLOW-FROM", frame_options_allow_from="https://partner.com",
          content_security_policy={"frame-ancestors": "https://partner.com"})
def embeddable_widget():
    return render_template("widget.html")

Per-route decorator:

from functools import wraps

def frameable_by(*origins):
    def decorator(f):
        @wraps(f)
        def decorated(*args, **kwargs):
            response = make_response(f(*args, **kwargs))
            ancestors = " ".join(origins) if origins else "'none'"
            response.headers["Content-Security-Policy"] = f"frame-ancestors {ancestors}"
            # Remove conflicting X-Frame-Options
            response.headers.pop("X-Frame-Options", None)
            return response
        return decorated
    return decorator

@app.route("/public/widget")
@frameable_by("https://partner1.com", "https://partner2.com")
def public_widget():
    return render_template("widget.html")

FastAPI implementation

from fastapi import FastAPI
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request

class ClickjackingMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, default_policy: str = "'none'"):
        super().__init__(app)
        self.default_policy = default_policy

    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)
        if "Content-Security-Policy" not in response.headers:
            response.headers["X-Frame-Options"] = "DENY"
            response.headers["Content-Security-Policy"] = (
                f"frame-ancestors {self.default_policy}"
            )
        return response

app = FastAPI()
app.add_middleware(ClickjackingMiddleware)

# Per-route override using response headers
@app.get("/embed/chart")
async def embeddable_chart():
    from fastapi.responses import HTMLResponse
    response = HTMLResponse(content=render_chart())
    response.headers["Content-Security-Policy"] = (
        "frame-ancestors 'self' https://dashboard.partner.com"
    )
    response.headers.pop("X-Frame-Options", None)
    return response

JavaScript frame-busting as defense in depth

Header-based protection should be your primary defense, but JavaScript frame-busting adds a second layer for older browsers:

// Modern frame-busting
if (window.self !== window.top) {
    // We're in a frame — break out or hide content
    document.documentElement.style.display = 'none';
    window.top.location = window.self.location;
}

This has weaknesses: attackers can use the sandbox attribute on iframes to prevent JavaScript execution, and the X-Frame-Options header approach doesn’t rely on client-side scripting. Use JavaScript frame-busting only as an additional layer, never as the sole defense.

A more robust pattern combines CSS hiding with JavaScript:

<style>
  /* Hide everything by default */
  body { display: none !important; }
</style>
<script>
  // Only show content if we're the top frame
  if (window.self === window.top) {
    document.body.style.display = 'block';
  }
</script>

This way, if the frame-busting JavaScript is blocked, the content stays hidden.

Double-click and drag-and-drop variants

Modern clickjacking extends beyond single clicks:

Double-click jacking: The attacker shows a legitimate page that requires a double-click, swapping the iframe into position between the first and second click. The first click lands on the attacker’s page, and the second click (which happens very fast) lands on the target page’s button.

Drag-and-drop jacking: The attacker tricks users into dragging content (like an authentication token displayed as text) from an invisible iframe into a visible input field on the attacker’s page.

These variants are harder to defend against purely with framing protections. Additional mitigations include requiring user interaction confirmations (typing a word, solving a CAPTCHA) for sensitive actions and implementing the Sec-Fetch-Dest header check:

class SecFetchMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # Block requests embedded in frames for sensitive endpoints
        if request.url.path.startswith("/api/sensitive/"):
            sec_fetch_dest = request.headers.get("Sec-Fetch-Dest", "")
            if sec_fetch_dest == "iframe":
                return Response(status_code=403, content="Framed requests blocked")
        return await call_next(request)

Testing and verification

Automated testing for clickjacking protection:

import pytest
from fastapi.testclient import TestClient

def test_clickjacking_headers(client: TestClient):
    response = client.get("/dashboard")
    assert response.headers.get("X-Frame-Options") == "DENY"
    csp = response.headers.get("Content-Security-Policy", "")
    assert "frame-ancestors 'none'" in csp or "frame-ancestors 'self'" in csp

def test_embeddable_route_allows_partner(client: TestClient):
    response = client.get("/embed/widget")
    csp = response.headers.get("Content-Security-Policy", "")
    assert "https://partner.com" in csp
    # Should NOT have DENY
    assert response.headers.get("X-Frame-Options") != "DENY"

Use security scanners like securityheaders.com to verify your headers in production, and include header checks in your CI/CD pipeline.

The one thing to remember: Clickjacking defense requires sending frame-ancestors 'none' (or X-Frame-Options: DENY) by default on all responses, with per-route exceptions only for genuinely embeddable pages that whitelist specific trusted origins — and JavaScript frame-busting serves as defense-in-depth, not a primary control.

pythonsecurityweb

See Also