Clickjacking
Clickjacking at a glance
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.
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.
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.
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.
// 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>');
});// 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. Inspect response headers
httpAction
Request the /settings page and examine HTTP response headers for anti-framing protections
Request
GET https://app.example.com/settingsResponse
Status: 200Body:{ "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. Test iframe embedding
browserAction
Create a test HTML page that embeds /settings in an iframe to confirm it renders
Request
ANALYSIS N/A - Analysis stepResponse
Status: 200Body:{ "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. Identify sensitive actions
analysisAction
Analyze the /settings page to identify state-changing buttons like 'Delete account', 'Disable MFA', or 'Change email'
Request
ANALYSIS N/A - Analysis stepResponse
Status: 200Body:{ "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. Check additional sensitive pages
httpAction
Test other critical endpoints like /transfer, /admin, /oauth/authorize for frame protections
Request
GET https://app.example.com/transferResponse
Status: 200Body:{ "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. Craft malicious overlay page
Build clickjacking exploit
analysisAction
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 stepResponse
Status: 200Body:{ "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. Social engineer victim visit
Lure victim to malicious page
analysisAction
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 stepResponse
Status: 200Body:{ "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. Execute hidden action via victim click
Trigger framed button click
httpAction
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/deleteHeaders:Cookie: <VICTIM_SESSION>Response
Status: 200Body:{ "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. Confirm exploitation success
Verify account destruction
analysisAction
Victim realizes their account has been deleted without their intention, demonstrating successful clickjacking attack
Request
ANALYSIS N/A - Analysis stepResponse
Status: 200Body:{ "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