How to Prevent Clickjacking Attacks in JavaScript
Clickjacking is one of the most deceptive web security attacks. Unlike phishing or cross-site scripting, clickjacking does not require the attacker to find a vulnerability in your code. Instead, it exploits the browser's ability to embed any page inside an iframe, tricking users into clicking on your legitimate site while believing they are interacting with something else entirely.
Understanding how clickjacking works and how to defend against it is essential for any web developer. A single unprotected page can be exploited to transfer money, change account settings, delete data, or grant permissions, all with a single click from an unsuspecting user. This guide explains the attack mechanism, covers the two primary server-side defenses (X-Frame-Options and CSP frame-ancestors), and discusses client-side protections for situations where server headers are not available.
What Is Clickjacking?
Clickjacking (also called "UI redressing") is an attack where a malicious page overlays or hides your legitimate website inside an invisible iframe, positioning it so that when users click on what they think is the attacker's page, they are actually clicking on your site.
How the Attack Works
The attacker creates a page that looks innocent, perhaps a game, a fake download button, or a "claim your prize" page. Behind the scenes, your legitimate website is loaded in a transparent iframe positioned so that a sensitive button (like "Delete Account" or "Transfer Funds") aligns exactly with the visible decoy button.
Here is a simplified version of what an attacker's page looks like:
<!-- attacker's page: evil-site.com/trick.html -->
<style>
.decoy {
position: relative;
width: 500px;
height: 400px;
background: #f0f0f0;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
}
.hidden-iframe {
position: absolute;
top: 0;
left: 0;
width: 500px;
height: 400px;
opacity: 0; /* Completely invisible */
z-index: 1; /* Sits on top of the decoy */
pointer-events: auto; /* Receives clicks */
}
</style>
<div style="position: relative;">
<!-- This is what the user SEES -->
<div class="decoy">
🎁 Click here to claim your free prize!
</div>
<!-- This is your REAL site, invisible, on top -->
<iframe class="hidden-iframe"
src="https://your-bank.com/transfer?to=attacker&amount=1000">
</iframe>
</div>
The user sees a friendly "claim your prize" button. When they click it, they are actually clicking the "Confirm Transfer" button on the bank's website loaded in the invisible iframe. Because the user is already logged into the bank (their cookies are sent with the iframe request), the transfer goes through.
Why It Is Dangerous
Clickjacking is particularly dangerous because:
- The user performs a genuine action: The click is real. The browser sees a legitimate user interaction on a legitimate page. There is no forged request.
- Authentication is preserved: The iframe loads with the user's session cookies, so the user is fully authenticated on the target site.
- No vulnerabilities needed: The attacker does not exploit a bug in your code. They exploit the fact that your page can be framed.
- Hard to detect: The user sees nothing suspicious. The attack page looks normal. The iframe is invisible.
- Single click is enough: Unlike some attacks that require multiple steps, a single click can trigger the malicious action.
Variations of the Attack
Classic clickjacking: An invisible iframe overlays a visible decoy, as shown above.
Likejacking: The hidden iframe contains a social media "Like" or "Share" button. Users unknowingly like or share content, spreading it virally.
Cursorjacking: The attacker changes the cursor's appearance or position, making the user think they are clicking in one place when they are actually clicking elsewhere.
Multi-step clickjacking: The attacker guides the user through multiple clicks by repositioning the iframe between clicks, completing a multi-step action (like changing an email address and confirming the change).
Drag-and-drop clickjacking: The attacker tricks the user into dragging content from the invisible iframe, potentially extracting data or completing actions that require drag-and-drop.
A Visual Demonstration
To see what the attacker sees while developing the attack, they typically start with a semi-transparent iframe:
<!-- Attacker's development view (opacity lowered for positioning) -->
<style>
iframe {
position: absolute;
top: 120px; /* Adjusted so the target button aligns with the decoy */
left: 50px;
width: 600px;
height: 400px;
opacity: 0.3; /* Semi-transparent during development */
z-index: 1;
}
</style>
<button style="position: absolute; top: 200px; left: 150px; padding: 20px; font-size: 18px;">
Click to Play Game
</button>
<iframe src="https://target-site.com/settings/delete-account"></iframe>
The attacker adjusts the top and left values until the "Delete Account" button on the target site lines up perfectly with the "Click to Play Game" button. Then they set opacity: 0 to make the iframe invisible, and the trap is set.
Clickjacking is not a theoretical threat. Major platforms including Facebook, Twitter, and Google have been targeted by clickjacking attacks. Any website that does not explicitly prevent framing is potentially vulnerable.
Defense: X-Frame-Options Header
The most established defense against clickjacking is the X-Frame-Options HTTP response header. When your server sends this header, the browser refuses to display your page inside an iframe on unauthorized sites.
Header Values
The X-Frame-Options header accepts three values:
DENY: The page cannot be displayed in any iframe, ever. Not even on the same site.
X-Frame-Options: DENY
SAMEORIGIN: The page can only be displayed in an iframe on pages from the same origin.
X-Frame-Options: SAMEORIGIN
ALLOW-FROM uri: The page can only be displayed in an iframe on the specified origin.
X-Frame-Options: ALLOW-FROM https://trusted-site.com
ALLOW-FROM is not supported in modern browsers. Chrome, Firefox, and Safari all ignore it. If you need to allow framing from specific origins, use the CSP frame-ancestors directive instead (covered in the next section). Only DENY and SAMEORIGIN are reliably supported.
Setting the Header on Your Server
Node.js / Express:
const express = require('express');
const app = express();
// Apply to all responses
app.use((req, res, next) => {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
next();
});
// Or per-route
app.get('/admin', (req, res) => {
res.setHeader('X-Frame-Options', 'DENY');
res.sendFile('admin.html');
});
Using the popular helmet middleware:
const helmet = require('helmet');
app.use(helmet.frameguard({ action: 'sameorigin' }));
// or
app.use(helmet.frameguard({ action: 'deny' }));
Nginx:
# Apply globally
add_header X-Frame-Options "SAMEORIGIN" always;
# Or for specific locations
location /admin {
add_header X-Frame-Options "DENY" always;
}
Apache:
# In .htaccess or server config
Header always set X-Frame-Options "SAMEORIGIN"
# For specific directories
<Directory "/var/www/html/admin">
Header always set X-Frame-Options "DENY"
</Directory>
Python / Django:
# settings.py
X_FRAME_OPTIONS = 'SAMEORIGIN' # Django sets this by default
# Or per-view using a decorator
from django.views.decorators.clickjacking import xframe_options_deny
@xframe_options_deny
def admin_view(request):
...
What Happens When the Header Is Set
When a browser loads a page with X-Frame-Options: DENY and that page is inside an iframe, the browser blocks the page from rendering inside the iframe. Instead of your content, the user sees a blank frame or an error message.
<!-- On a page with X-Frame-Options: DENY -->
<iframe src="https://protected-site.com/page"></iframe>
<!-- The iframe shows a blank page or browser error message -->
<!-- The console shows: "Refused to display 'https://protected-site.com/page'
in a frame because it set 'X-Frame-Options' to 'DENY'" -->
Choosing Between DENY and SAMEORIGIN
Use DENY when your page should never appear in any iframe, even on your own site. This is the safest option for sensitive pages like login forms, admin panels, and payment pages.
Use SAMEORIGIN when your page needs to be embeddable within your own site (for example, a page that appears in an internal dashboard iframe) but should not be framed by external sites.
// Common pattern: different settings for different routes
app.use((req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/admin/')) {
res.setHeader('X-Frame-Options', 'DENY');
} else if (req.path.startsWith('/embeddable/')) {
// No X-Frame-Options (this content is meant to be embedded)
// Use CSP frame-ancestors for fine-grained control
} else {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
}
next();
});
Limitations of X-Frame-Options
While effective and widely supported, X-Frame-Options has several limitations:
- No multiple origins: You cannot specify a list of allowed origins.
ALLOW-FROMaccepts only one URI and is not supported in modern browsers anyway. - No wildcard support: You cannot use patterns like
*.mysite.com. - Being superseded: The CSP
frame-ancestorsdirective is the modern replacement and offers more flexibility. - Double framing: In some older browser implementations,
SAMEORIGINonly checks the immediate parent, not the entire frame hierarchy.
For these reasons, best practice is to use both X-Frame-Options and CSP frame-ancestors together for maximum compatibility.
Defense: CSP frame-ancestors
The Content Security Policy frame-ancestors directive is the modern, more flexible replacement for X-Frame-Options. It specifies which origins are allowed to embed your page in an iframe, frame, object, or embed element.
Syntax
The frame-ancestors directive is set as part of the Content-Security-Policy HTTP header:
Content-Security-Policy: frame-ancestors <source-list>;
Directive Values
'none': No embedding allowed (equivalent to X-Frame-Options: DENY):
Content-Security-Policy: frame-ancestors 'none';
'self': Only same-origin embedding (equivalent to X-Frame-Options: SAMEORIGIN):
Content-Security-Policy: frame-ancestors 'self';
Specific origins: Allow embedding from listed origins:
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com https://dashboard.example.com;
Wildcard subdomains: Allow all subdomains of a domain:
Content-Security-Policy: frame-ancestors 'self' https://*.mycompany.com;
Multiple origins and patterns:
Content-Security-Policy: frame-ancestors 'self' https://app.partner1.com https://*.partner2.com https://dashboard.internal.com;
Setting the Header
Node.js / Express:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"frame-ancestors 'self' https://trusted-site.com"
);
next();
});
Using helmet:
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
frameAncestors: ["'self'", "https://trusted-partner.com"]
}
}));
Nginx:
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-partner.com" always;
Apache:
Header always set Content-Security-Policy "frame-ancestors 'self' https://trusted-partner.com"
Why frame-ancestors Is Better Than X-Frame-Options
| Feature | X-Frame-Options | CSP frame-ancestors |
|---|---|---|
| Multiple allowed origins | No | Yes |
| Wildcard subdomains | No | Yes (*.example.com) |
| Granular scheme control | No | Yes (https: only) |
| Part of a broader security policy | No | Yes (CSP) |
ALLOW-FROM support | Deprecated, unsupported | Fully supported syntax |
| Browser support | All browsers | All modern browsers |
| Checks entire frame hierarchy | Inconsistent | Yes |
Using Both Headers Together
For maximum compatibility with older browsers, set both headers. When both are present, modern browsers use frame-ancestors and ignore X-Frame-Options:
app.use((req, res, next) => {
// Modern browsers use this
res.setHeader(
'Content-Security-Policy',
"frame-ancestors 'self' https://trusted-site.com"
);
// Older browsers fall back to this
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
next();
});
# Nginx: both headers
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
Per-Page Configuration
Different pages on your site may have different framing requirements:
function setFramingPolicy(req, res, next) {
const path = req.path;
if (path.startsWith('/login') || path.startsWith('/admin') || path.startsWith('/account')) {
// Sensitive pages: never allow framing
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res.setHeader('X-Frame-Options', 'DENY');
} else if (path.startsWith('/embed/') || path.startsWith('/widget/')) {
// Embeddable content: allow specific partners
res.setHeader(
'Content-Security-Policy',
"frame-ancestors 'self' https://partner-a.com https://partner-b.com https://*.trusted-network.com"
);
// X-Frame-Options cannot express this, so omit it or use SAMEORIGIN as fallback
} else {
// General pages: same-origin only
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'");
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
}
next();
}
app.use(setFramingPolicy);
Important Notes About frame-ancestors
The frame-ancestors directive has a few special characteristics:
- Cannot be set via
<meta>tag: Unlike other CSP directives,frame-ancestorsmust be set via an HTTP header. It is ignored in<meta http-equiv="Content-Security-Policy">tags.
<!-- ❌ This does NOT work for frame-ancestors -->
<meta http-equiv="Content-Security-Policy" content="frame-ancestors 'none'">
-
Checks the entire ancestor chain: If page A frames page B, which frames page C, then page C's
frame-ancestorspolicy must allow both page B's origin and page A's origin. -
Takes precedence over X-Frame-Options: When both headers are present, browsers that support CSP use
frame-ancestorsand ignoreX-Frame-Options.
Client-Side Protections
Server-side headers are the primary defense against clickjacking, but there are situations where you might need client-side protection: when you cannot control server headers (static hosting, CDNs), as a defense-in-depth layer, or for legacy browser support. Client-side protections are less reliable than server headers but provide an additional safety net.
Frame-Busting Scripts
The traditional client-side defense is a "frame-busting" script that detects if the page is inside an iframe and breaks out of it:
// Basic frame busting
if (window !== window.top) {
window.top.location = window.location;
}
However, basic frame busting can be defeated by the attacker. The attacker can prevent navigation by handling the beforeunload event or by using the sandbox attribute to restrict the iframe:
<!-- Attacker's page: defeating basic frame busting -->
<iframe src="https://target-site.com" sandbox="allow-scripts allow-forms"></iframe>
<!-- The sandbox attribute without "allow-top-navigation" prevents
the framed page from changing window.top.location -->
Improved Frame-Busting with Style Defense
A more robust approach combines detection with a visual defense. Hide the page content by default and only show it if the page is not framed:
<style id="antiClickjack">
body {
display: none !important;
}
</style>
<script>
if (window === window.top) {
// Not in a frame (remove the hiding style)
const style = document.getElementById('antiClickjack');
if (style) {
style.remove();
}
} else {
// In a frame (keep content hidden and try to break out)
try {
window.top.location = window.self.location;
} catch (e) {
// Cross-origin: can't navigate top, but content stays hidden
// The user sees nothing, so the clickjacking attempt fails
}
}
</script>
This approach is recommended by OWASP as a client-side defense. Even if the attacker prevents the navigation, the page content remains invisible, making clickjacking impossible because there is nothing for the user to click on.
Why Client-Side Protections Are Not Enough
Client-side protections have fundamental limitations:
-
JavaScript can be disabled: If the user (or the attacker's page via iframe sandbox) disables JavaScript, the frame-busting script never runs.
-
Race conditions: The attacker might trick the user into clicking before the frame-busting script executes.
-
sandboxattribute bypass: Thesandboxattribute on the iframe can preventallow-top-navigation, blocking the frame-busting attempt while still allowing the page to render and accept clicks. -
Timing attacks: The page content is visible for a brief moment before JavaScript hides it or navigates away, potentially long enough for a prepared attack.
<!-- Attacker defeats JavaScript-only frame busting -->
<iframe
src="https://target-site.com"
sandbox="allow-scripts allow-forms allow-same-origin"
></iframe>
<!-- No allow-top-navigation: the frame buster can't navigate top -->
<!-- But the page renders normally, enabling clickjacking -->
This is why server-side headers are the primary defense. Client-side scripts are a supplementary layer.
Always implement server-side protection (X-Frame-Options and/or CSP frame-ancestors) as your primary defense against clickjacking. Use client-side frame-busting scripts only as an additional layer of defense, never as the sole protection.
Detecting Framing for Analytics and Warnings
Even when server headers are in place, you might want to detect framing attempts for logging or showing warnings:
// Detect if the page is framed (for analytics/logging purposes)
function detectFraming() {
if (window === window.top) {
return { framed: false };
}
let parentOrigin = 'unknown';
try {
// This will throw for cross-origin parents
parentOrigin = window.parent.location.origin;
} catch (e) {
parentOrigin = 'cross-origin (blocked)';
}
return {
framed: true,
parentOrigin,
depth: getFrameDepth()
};
}
function getFrameDepth() {
let depth = 0;
let current = window;
while (current !== current.top) {
depth++;
current = current.parent;
if (depth > 10) break; // Safety limit
}
return depth;
}
// Log framing attempts
const framingInfo = detectFraming();
if (framingInfo.framed) {
console.warn('Page is framed!', framingInfo);
// Report to your analytics
fetch('/api/security/framing-detected', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(framingInfo)
}).catch(() => {}); // Fire and forget
}
Protecting Sensitive Actions with Confirmation
For critical actions, add an extra confirmation step that is harder to clickjack. Even if the attacker frames your page, a multi-step confirmation with changing button positions makes the attack much harder:
function confirmDangerousAction(actionName, callback) {
// Step 1: Show a confirmation dialog that requires typing
const confirmation = prompt(
`To confirm "${actionName}", type "${actionName}" below:`
);
if (confirmation !== actionName) {
alert('Action cancelled: confirmation did not match.');
return;
}
// Step 2: Add a delay and second confirmation
const dialog = document.createElement('div');
dialog.innerHTML = `
<div style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:99999;
display:flex;align-items:center;justify-content:center;">
<div style="background:white;padding:30px;border-radius:12px;max-width:400px;text-align:center;">
<h3 style="color:#e74c3c;">Are you absolutely sure?</h3>
<p>This action cannot be undone.</p>
<p>Click the button below to proceed.</p>
<button id="confirm-final" style="margin-top:20px;padding:12px 24px;
background:#e74c3c;color:white;border:none;border-radius:6px;
font-size:16px;cursor:pointer;">
Yes, ${actionName}
</button>
<button id="cancel-final" style="margin-top:20px;margin-left:10px;padding:12px 24px;
background:#95a5a6;color:white;border:none;border-radius:6px;
font-size:16px;cursor:pointer;">
Cancel
</button>
</div>
</div>
`;
document.body.appendChild(dialog);
document.getElementById('confirm-final').addEventListener('click', () => {
dialog.remove();
callback();
});
document.getElementById('cancel-final').addEventListener('click', () => {
dialog.remove();
});
}
// Usage
document.getElementById('delete-account-btn').addEventListener('click', () => {
confirmDangerousAction('DELETE ACCOUNT', async () => {
await fetch('/api/account', { method: 'DELETE' });
window.location.href = '/goodbye';
});
});
Clickjacking attacks rely on tricking the user into a single click. A typed confirmation or a multi-step dialog with distinct visuals is extremely difficult to frame invisibly.
Using Anti-CSRF Tokens
While not a direct clickjacking defense, anti-CSRF tokens complement clickjacking protection. Even if an attacker successfully clickjacks a form submission, the request will fail without a valid CSRF token:
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="random-token-from-server" />
<input type="number" name="amount" />
<button type="submit">Transfer</button>
</form>
The attacker cannot read the CSRF token from the iframe (blocked by same-origin policy), so even if the user clicks the submit button through a clickjacking attack, the form submission fails because it includes the correct CSRF token only when the form is rendered normally on the legitimate page.
SameSite Cookies as Additional Protection
Setting cookies with the SameSite attribute provides another layer. When cookies are SameSite=Strict or SameSite=Lax, the browser does not send them with requests from a cross-origin iframe:
Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
With SameSite=Strict, if your page is loaded in a cross-origin iframe, the session cookie is not sent. This means the user appears logged out inside the iframe, and any authenticated action fails. The attacker cannot clickjack authenticated actions because the framed page has no active session.
// Server-side: set secure cookie options
app.use(session({
cookie: {
sameSite: 'strict', // or 'lax' for slightly less restrictive
secure: true,
httpOnly: true
}
}));
Complete Defense Strategy
A robust clickjacking defense uses multiple layers:
// Express.js middleware implementing complete clickjacking defense
function clickjackingDefense(options = {}) {
const {
defaultPolicy = 'self', // 'none', 'self', or array of origins
sensitiveRoutes = [], // Routes that should never be framed
embeddableRoutes = [], // Routes that can be framed by partners
allowedPartners = [] // Partner origins for embeddable routes
} = options;
return (req, res, next) => {
const path = req.path;
// Sensitive routes: never allow framing
if (sensitiveRoutes.some(route => path.startsWith(route))) {
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res.setHeader('X-Frame-Options', 'DENY');
next();
return;
}
// Embeddable routes: allow specific partners
if (embeddableRoutes.some(route => path.startsWith(route)) && allowedPartners.length > 0) {
const origins = ["'self'", ...allowedPartners].join(' ');
res.setHeader('Content-Security-Policy', `frame-ancestors ${origins}`);
// X-Frame-Options can't express multiple origins, use SAMEORIGIN as fallback
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
next();
return;
}
// Default policy
if (defaultPolicy === 'none') {
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
res.setHeader('X-Frame-Options', 'DENY');
} else if (defaultPolicy === 'self') {
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'");
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
}
next();
};
}
// Usage
app.use(clickjackingDefense({
defaultPolicy: 'self',
sensitiveRoutes: ['/login', '/admin', '/account', '/payment'],
embeddableRoutes: ['/embed/', '/widget/'],
allowedPartners: ['https://partner-a.com', 'https://*.partner-b.com']
}));
Testing Your Defenses
Verify your clickjacking protection by attempting to frame your own pages:
<!-- test-clickjacking.html (run on a different origin or locally) -->
<!DOCTYPE html>
<html>
<head><title>Clickjacking Test</title></head>
<body>
<h1>Clickjacking Defense Test</h1>
<h2>Test 1: Login page (should be blocked)</h2>
<iframe src="https://yoursite.com/login"
width="600" height="400"
style="border: 2px solid red;">
</iframe>
<h2>Test 2: General page (should work on same origin only)</h2>
<iframe src="https://yoursite.com/about"
width="600" height="400"
style="border: 2px solid orange;">
</iframe>
<h2>Test 3: Embeddable widget (should work)</h2>
<iframe src="https://yoursite.com/embed/widget"
width="600" height="400"
style="border: 2px solid green;">
</iframe>
<p>Check the browser console for CSP/X-Frame-Options violations.</p>
</body>
</html>
You can also check headers using browser DevTools (Network tab) or command-line tools:
# Check response headers
curl -I https://yoursite.com/login
# Look for:
# X-Frame-Options: DENY
# Content-Security-Policy: frame-ancestors 'none'
Summary
Clickjacking is a deceptive attack that tricks users into clicking on your legitimate site through invisible iframes. Defending against it requires a layered approach:
X-Frame-Optionsheader is the established defense, supported by all browsers. UseDENYfor pages that should never be framed andSAMEORIGINfor pages that need same-origin framing. TheALLOW-FROMvalue is deprecated and unsupported in modern browsers.- CSP
frame-ancestorsdirective is the modern replacement, offering multiple origins, wildcard subdomain support, and integration with the broader Content Security Policy. It must be set via HTTP headers, not<meta>tags. Use it alongsideX-Frame-Optionsfor backward compatibility. - Client-side frame-busting scripts provide a supplementary defense layer but should never be the sole protection. The CSS-hiding approach (hiding content by default and showing it only when not framed) is more robust than navigation-based frame busting.
- Additional layers like SameSite cookies, CSRF tokens, and multi-step confirmations for sensitive actions make clickjacking attacks harder even if framing protection is somehow bypassed.
- Always set framing policies per-route:
'none'for sensitive pages (login, admin, payments),'self'as a general default, and specific partner origins for intentionally embeddable content.