
What is clickjacking and how to prevent it?
Clickjacking is an attack where a malicious website loads your site inside an invisible or disguised frame and tricks visitors into clicking buttons or links on your page without realizing it, such as confirming a purchase, changing a setting, or granting a permission. You prevent it by telling browsers not to let other sites frame your pages, using the Content-Security-Policy: frame-ancestors directive and the older X-Frame-Options header.
The name combines "click" and "hijacking," and the attack is also known as a UI redress attack. It's simple, effective against unprotected sites, and fortunately very easy to prevent. This article explains how clickjacking works, what attackers can do with it, and how to add the right protections on Apache, Nginx, WordPress, Next.js, Express, and Cloudflare, plus how to test that they're working.
How Does Clickjacking Work?
Browsers allow one web page to embed another using an iframe element. That's how embedded videos, maps, and payment widgets work. Clickjacking abuses this feature.
Here's the typical sequence:
-
The attacker builds a decoy page: It shows something enticing, such as a "Click to claim your prize" button, a game, or a video play button.
-
Your site is loaded in a hidden frame: The attacker embeds a page from your site, for example an account settings page, in an iframe and makes it fully transparent with CSS, positioning it precisely over the decoy.
-
The victim is logged in to your site: Because browsers send cookies with framed requests (depending on cookie settings), the framed page shows the victim's authenticated view.
-
The victim clicks: They think they're clicking the decoy button, but they're actually clicking a real button on your page underneath, such as "Delete account," "Make profile public," or "Confirm."
A simplified illustration of the technique looks like this:
<style>
#target {
position: absolute;
top: 120px;
left: 80px;
width: 500px;
height: 300px;
opacity: 0;
z-index: 2;
}
#decoy {
position: absolute;
top: 300px;
left: 250px;
z-index: 1;
}
</style>
<button id="decoy">Click to play</button>
<iframe id="target" src="https://example.com/account/settings"></iframe>
The user sees only the "Click to play" button. The real click lands on whatever element of the framed page sits at that spot.
Variations of Clickjacking
Clickjacking comes in several forms:
- Classic clickjacking: Tricking a user into clicking a single button on a hidden page.
- Likejacking: Tricking users into liking or sharing social media content.
- Cursorjacking: Displaying a fake cursor offset from the real one, so the user clicks somewhere other than where they think.
- Multi-step clickjacking: Guiding users through several clicks, for example a fake game, to complete a multi-step action on the framed site.
- Drag-and-drop and keystroke hijacking: Tricking users into dragging content or typing into hidden fields.
- Permission hijacking: Tricking users into granting access to their camera, microphone, or notifications.
What Can Attackers Achieve?
The damage depends on what actions a single click (or a few clicks) can perform on your site:
- Changing account or privacy settings.
- Deleting content or accounts.
- Making purchases or donations with one-click checkout.
- Following, liking, or sharing content.
- Approving OAuth permissions to third-party apps.
- In an admin dashboard, activating a plugin or approving a user.
Any sensitive action that can be completed by clicking, without requiring typed secrets, is a potential target.
How to Prevent Clickjacking
The main defense happens in the browser: you send a response header that tells it which sites, if any, may frame your pages. If the header forbids framing, the browser refuses to render your page inside the attacker's iframe.
Option 1: Content-Security-Policy frame-ancestors (Recommended)
The modern standard is the CSP frame-ancestors directive. It's flexible and supported by all current browsers.
- Block all framing:
Content-Security-Policy: frame-ancestors 'none' - Allow only your own site:
Content-Security-Policy: frame-ancestors 'self' - Allow specific trusted sites:
Content-Security-Policy: frame-ancestors 'self' https://partner.example.net
frame-ancestors must be sent as an HTTP header. It is ignored if you put it in a meta tag.
Option 2: X-Frame-Options (Legacy)
X-Frame-Options is the older header, still widely used for compatibility with legacy browsers:
X-Frame-Options: DENYblocks all framing.X-Frame-Options: SAMEORIGINallows only your own origin to frame the page.
The old ALLOW-FROM value is obsolete and not supported by modern browsers, so use frame-ancestors if you need to allow specific sites. When both headers are present, modern browsers prioritize frame-ancestors. Sending both is a sensible belt-and-braces approach.
Which Value Should You Use?
- If your site never needs to be framed, use
frame-ancestors 'none'andX-Frame-Options: DENY. - If your own pages frame each other, which WordPress does in the Customizer and some admin features, use
'self'andSAMEORIGIN.
For most WordPress sites, SAMEORIGIN is the safer default because it avoids breaking the Customizer preview and similar features.
Adding Clickjacking Protection to Your Server
Always back up configuration files before editing them, and test your site afterwards.
Apache (.htaccess or Virtual Host)
This requires mod_headers, which is enabled on most hosts:
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Content-Security-Policy "frame-ancestors 'self'"
</IfModule>
If you already send a Content Security Policy, add frame-ancestors 'self' to your existing policy rather than setting a second Content-Security-Policy header, since multiple policies combine in ways that can be confusing.
Nginx
Add these to your server block, then test and reload:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
sudo nginx -t && sudo systemctl reload nginx
Remember that in Nginx, add_header directives in a location block replace, rather than add to, those defined at the server level. If a location has its own add_header lines, repeat the security headers there.
WordPress (PHP)
WordPress already sends X-Frame-Options: SAMEORIGIN on the login page and admin screens using the send_frame_options_header() function. To protect the front end too, you can add headers from a small custom plugin or your child theme's functions.php:
add_action( 'send_headers', 'sajjad_send_anti_clickjacking_headers' );
function sajjad_send_anti_clickjacking_headers() {
if ( headers_sent() ) {
return;
}
header( 'X-Frame-Options: SAMEORIGIN' );
header( "Content-Security-Policy: frame-ancestors 'self'" );
}
Setting headers at the server level is generally more reliable, because it also covers static files and cached pages served without running PHP. Some security plugins, like Headers Security Advanced & HSTS WP, can add these headers for you without code.
Next.js
In a Next.js project, add headers in next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
],
},
];
},
};
module.exports = nextConfig;
If you already use a fuller CSP, include frame-ancestors in that policy instead. A separate article on this blog covers setting up a complete Content Security Policy in Next.js.
Node.js with Express
The Helmet middleware sets sensible security headers, including frame-ancestors and X-Frame-Options:
import express from "express";
import helmet from "helmet";
const app = express();
app.use(
helmet({
contentSecurityPolicy: {
directives: {
frameAncestors: ["'self'"],
},
},
frameguard: { action: "sameorigin" },
}),
);
Cloudflare
If your site is behind Cloudflare, you can add response headers at the edge using a response header transform rule (in the dashboard under Rules). This is handy when you can't easily change server configuration. Menu locations change over time, so check Cloudflare's documentation for the current path.
Additional Defenses
Headers are the primary fix, but a few extra measures help:
- SameSite cookies: Cookies set with
SameSite=LaxorStrictaren't sent to your site when it's loaded in a cross-site iframe, so the framed page appears logged out. WordPress core cookies don't setSameSiteexplicitly, but Chromium-based browsers treat cookies without it asLaxby default, so don't rely on this alone. - Confirm sensitive actions: Require re-entering a password or a 2FA code for critical actions like deleting accounts or changing email addresses.
- Avoid one-click destructive actions: Add a confirmation step that requires typing or a deliberate second interaction.
What About Frame-Busting JavaScript?
Older sites used JavaScript snippets that checked whether the page was framed and tried to break out. These are unreliable, can be bypassed using iframe sandbox attributes, and aren't needed in modern browsers. Use headers instead.
How to Test Your Clickjacking Protection
Check Your Headers
From the command line:
curl -sI https://example.com | grep -iE 'x-frame-options|content-security-policy'
You should see your X-Frame-Options value and a CSP that includes frame-ancestors. You can also open your browser's developer tools, reload the page, and inspect the response headers in the Network tab.
Try Framing Your Site
Create a local test HTML file on your computer:
<!doctype html>
<html>
<body>
<h1>Framing test</h1>
<iframe src="https://example.com" width="800" height="600"></iframe>
</body>
</html>
Open it in a browser. If protection is working, the iframe will be blank or show an error, and the developer console will report that the page refused to be framed.
Online tools like Security Headers (securityheaders.com) and Mozilla Observatory also check for these headers.
Pages That Legitimately Need Framing
Sometimes you want your content embedded elsewhere, such as a booking widget on a partner's site. In that case:
- Scope the allowance: Apply a permissive
frame-ancestorsonly to the specific paths that need embedding, not the whole site. - List exact origins: Allow specific trusted domains rather than wildcards.
- Keep sensitive pages protected: Login, account, checkout, and admin pages should never be frameable by other sites.
In Nginx, for example, you can set a different policy for one path:
location /embed/ {
add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example.net" always;
try_files $uri $uri/ /index.php?$args;
}
FAQ: Clickjacking
Clickjacking is when a malicious site hides your website in an invisible frame and tricks visitors into clicking buttons on your site while they think they're clicking something harmless on the attacker's page.
Use frame-ancestors in your Content Security Policy as the modern standard, and add X-Frame-Options for compatibility with older browsers. Modern browsers give frame-ancestors priority when both are present.
Partly. WordPress sends X-Frame-Options SAMEORIGIN on the login page and admin screens, but not on front-end pages. You need to add headers yourself, through your server config, a plugin, or code, to cover the whole site.
Using SAMEORIGIN or self rarely breaks anything. Using DENY or none can break features that frame your own pages, like the WordPress Customizer preview, so test after making changes.
No. Browsers ignore frame-ancestors when it's delivered in a meta tag. It must be sent as an HTTP response header.
Yes, for sites that don't send framing headers. The fix is simple and well supported, so there's little reason not to apply it everywhere.
Conclusion
Clickjacking tricks visitors into clicking hidden elements of your site by loading it inside an invisible or disguised frame on an attacker's page. Because the victim is often logged in, those clicks can change settings, delete data, approve permissions, or make purchases, all without the user realizing what they've done.
Preventing it is one of the easiest security wins available. Send Content-Security-Policy: frame-ancestors 'self' (or 'none') along with X-Frame-Options: SAMEORIGIN (or DENY) from your server, CDN, or application, test that framing is blocked, and add confirmation steps for sensitive actions. A few lines of configuration close the door on this entire class of attack.


