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.
See Also
- Python Api Key Management Why apps use special passwords called API keys, and how to keep them safe — explained with a library card analogy
- Python Attribute Based Access Control How apps make fine-grained permission decisions based on who you are, what you're accessing, and the circumstances — explained with an airport analogy
- Python Audit Logging Learn Audit Logging with a clear mental model so your Python code is easier to trust and maintain.
- Python Bandit Security Scanning Why Bandit Security Scanning helps Python teams catch painful mistakes early without slowing daily development.
- Python Content Security Policy How websites create a guest list for scripts and styles to block hackers from sneaking in malicious code