Why only HTTP headers reliably block invisible iframes
Clickjacking tricks users with an invisible iframe layered over a real page, making them unknowingly trigger critical actions like deleting an account or confirming a transfer. Classic JavaScript frame-busting can be bypassed, while X-Frame-Options and the CSP directive frame-ancestors work as HTTP headers that reliably stop a page from being framed at all.
Table of Contents
- 1. How clickjacking works technically
- 2. Real-world attack scenarios: likejacking, cursorjacking, and opacity tricks
- 3. Why classic JavaScript frame-busting fails
- 4. Bypass techniques: sandbox, proxy stripping, and double framing
- 5. X-Frame-Options: DENY and SAMEORIGIN in detail
- 6. CSP frame-ancestors as the modern replacement
- 7. Combining both headers: defense in depth
- 8. Allowing legitimate iframe embeds cleanly
- 9. Testing whether a page is vulnerable to clickjacking
- 10. Summary
- 11. FAQ
1. How clickjacking works technically
Clickjacking, also known as UI redressing, places an invisible or nearly invisible layer of the real target page on top of a seemingly harmless decoy page. The attacker loads the target page inside an <iframe> with opacity: 0 or opacity: 0.01 and positions it exactly over a button on the visible decoy page, for example a "Start giveaway" button. When the victim clicks the apparently harmless button, the click actually lands on a button in the invisible target page underneath, such as "Delete account" or "Confirm transfer".
The attack works because browsers forward click events to the topmost visible, or CSS-positioned, element, regardless of whether that element is recognizable to the user. Using position: absolute, z-index, and precisely calculated pixel coordinates, the attacker aligns the iframe so the victim's critical button sits pixel-perfect beneath the visible decoy button. Because the user brings their own logged-in session of the target site along in the background, the action executes as authenticated without any additional confirmation step. This exact combination of session reuse and visual deception makes clickjacking a distinct threat class that cannot be solved by classic XSS or CSRF protection alone.
2. Real-world attack scenarios: likejacking, cursorjacking, and opacity tricks
Likejacking was the earliest popular clickjacking variant: an invisible Facebook "Like" button sat over a video play button, so every click on "Play" simultaneously liked a page without the user noticing. The technique spread virally because each like further increased the decoy page's reach. Similar patterns appear with newsletter signups, permission prompts for browser extensions, or social-media sharing actions hidden behind an innocuous-looking game or quiz.
Cursorjacking takes this a step further: CSS visually shifts the displayed mouse cursor so it appears at a different position than the browser's actual click point. The user believes they're clicking a specific button while the real click lands a few pixels away on a hidden element. With opacity tricks, the iframe isn't made fully invisible but only slightly transparent, combined with a matching background image that creates the illusion of a single, continuous page. Combined with fake form fields, this can hijack multi-step actions such as payment confirmations or permission dialogs in admin interfaces, if those interfaces can be embedded unprotected in a foreign page.
3. Why classic JavaScript frame-busting fails
Before HTTP headers gained broad support, developers tried to prevent clickjacking with client-side frame-busting. The classic pattern checks whether the current window is the topmost window and, if not, redirects the parent page to its own URL. The problem: this code runs in the victim's browser and can be controlled or completely suppressed by an attacker who embeds the page in an iframe, before it ever takes effect.
Frame-busting is therefore structurally an arms race on the wrong side of the trust boundary. The attacker controls the surrounding page and therefore every means of neutralizing the embedded JavaScript before it executes or before its effect takes hold. Security decisions made inside the JavaScript of the embedded page are inherently attackable, because the attacker controls the iframe's execution environment. That's why frame-busting has been considered an anti-pattern for years and has been replaced by server-set HTTP headers that the browser evaluates before rendering the page, independent of how the surrounding document behaves.
// INSECURE / LEGACY: classic frame-busting - do NOT rely on this
// Shown for educational contrast only, easily bypassed by an attacker
if (top !== self) {
top.location = self.location;
}
// A slightly more defensive variant, still unreliable
(function preventFraming() {
if (top !== self) {
document.body.style.display = 'none';
top.location = self.location;
}
})();
// Both approaches fail against sandboxed iframes, onbeforeunload
// tricks, or a stripping proxy - see section 4 for details.
// The reliable fix is a server-side header, not client-side JS:
// X-Frame-Options: DENY
// Content-Security-Policy: frame-ancestors 'none'
4. Bypass techniques: sandbox, proxy stripping, and double framing
An iframe's sandbox attribute without the allow-top-navigation token specifically prevents embedded code from redirecting the parent page. An attacker simply sets <iframe sandbox="allow-scripts allow-forms" src="...">: the target site's JavaScript still runs, but the browser silently blocks the top.location = self.location command. The frame-busting script fires but has no effect whatsoever, while the attack continues unnoticed.
A second bypass class uses proxy stripping: the attacker doesn't load the target page directly, but through their own reverse proxy that specifically strips the X-Frame-Options header while passing through the HTTP response, provided the target site doesn't also protect itself via CSP. With double framing, the target page is wrapped in a second, nested iframe to confuse legacy frame-busting scripts that only check a single nesting level. There are also onbeforeunload tricks, where the attacker intercepts the dialog that would appear when the frame-busting script attempts a redirect, and either hides it from the user or auto-dismisses it. All of these techniques point to the same core issue: client-side countermeasures are fundamentally defeatable because the attacker controls the embedding environment.
5. X-Frame-Options: DENY and SAMEORIGIN in detail
The HTTP header X-Frame-Options was the first server-side, browser-enforced defense against clickjacking, and the browser evaluates it before rendering the frame content at all. The value DENY forbids embedding the page in a frame under any circumstances, from any origin, even the page's own domain. The value SAMEORIGIN only allows framing when the embedding page shares the exact same origin as the embedded page, which is often the more practical choice for admin interfaces with internal iframes or multi-domain setups.
The decisive limitation of X-Frame-Options: the header does not support a list of allowed origins. There historically was a value ALLOW-FROM uri, but it was never reliably implemented across modern browsers and is considered obsolete. So if you need to allow several specific partner domains as frame parents at once, say for an embedded payment widget provider and a separate partner portal simultaneously, X-Frame-Options alone cannot express that. This exact gap is closed by the CSP directive frame-ancestors, described in the next section.
# nginx.conf: send X-Frame-Options for legacy browser coverage
# Place inside the server or location block serving the Magento storefront
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent MIME-sniffing alongside the framing protection
add_header X-Content-Type-Options "nosniff" always;
# Restrictive fallback for admin routes: no framing at all
location /admin_xyz123/ {
add_header X-Frame-Options "DENY" always;
proxy_pass http://magento_upstream;
}
6. CSP frame-ancestors as the modern replacement
The Content-Security-Policy directive frame-ancestors fully replaces X-Frame-Options functionally and goes considerably further. Instead of choosing only between "not at all", "same origin only", and a de facto non-existent allowlist mechanism, frame-ancestors accepts an arbitrary list of specific origins: Content-Security-Policy: frame-ancestors 'self' https://partner-a.example https://partner-b.example. This lets you express exactly which partner domains may embed a page, without allowing every other domain as well.
The value 'none' functionally matches X-Frame-Options: DENY, and 'self' matches SAMEORIGIN. On top of that, frame-ancestors supports wildcard subdomain patterns like https://*.mironsoft.de, which saves considerable configuration effort in multi-store setups with several subdomains. Important: frame-ancestors can only be set via the Content-Security-Policy HTTP header, not via a <meta> tag in the HTML, because the browser must decide on framing behavior before the page content is even parsed.
# .htaccess or Apache vhost config for the Magento pub/ document root
# frame-ancestors replaces X-Frame-Options with origin-list support
<IfModule mod_headers.c>
# Allow framing only from same origin plus two named partner domains
Header always set Content-Security-Policy "frame-ancestors 'self' https://payment-widget.example https://partner-portal.example"
# Legacy header for browsers that ignore frame-ancestors
Header always set X-Frame-Options "SAMEORIGIN"
</IfModule>
7. Combining both headers: defense in depth
Even though frame-ancestors is the more modern and powerful mechanism, sending X-Frame-Options at the same time is still recommended as an additional layer of protection. The reason is browser compatibility: older browser versions and some embedded webviews in mobile apps don't fully support frame-ancestors, but reliably honor X-Frame-Options. When both headers are set and conflict, modern browsers always favor the more restrictive CSP directive, while older browsers fall back to the classic header.
In practice this means: X-Frame-Options: SAMEORIGIN together with Content-Security-Policy: frame-ancestors 'self' https://partner.example covers both the modern use case with multiple allowed origins and legacy compatibility. This principle of layered protection, known as defense in depth, is standard security practice: a single control mechanism that fails or doesn't apply in an edge case should never be the only protective layer. For Magento stores running a Varnish Full Page Cache, it's also important that both headers are consistently attached to every response across all cache layers, not just to dynamically generated pages.
8. Allowing legitimate iframe embeds cleanly
Not every embed is an attack. Payment providers such as 3-D Secure widgets, PayPal buttons, or Klarna checkout components technically run inside an iframe embedded by your own store domain, but conversely the payment page itself also needs to control who is allowed to embed it. The Magento backend also uses iframes internally, for instance for certain WYSIWYG editors or import/export previews, and partner programs frequently need embeddable widgets, such as product review or price comparison components, meant to be rendered on third-party sites.
For these cases: instead of banning framing entirely, configure frame-ancestors with a tight, explicitly maintained list of specific origins. A generic * in frame-ancestors should be avoided, except for deliberately public, embeddable widget pages that don't contain sensitive actions. For Magento, a plugin on the HTTP response that sets a different frame-ancestors value depending on the route works well, restrictive for /customer/account but more permissive for a dedicated widget route like /widget/pricecompare. It's important to document every allowlist addition and regularly verify that the listed partner domain is still actively in use, since stale entries leave unnecessary attack surface open.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Plugin;
use Magento\Framework\App\Response\Http;
use Magento\Framework\App\Request\Http as HttpRequest;
/**
* Sets clickjacking-protection headers on every HTTP response,
* with a narrow allowlist for routes that legitimately need framing.
*/
class ClickjackingHeadersPlugin
{
/**
* @var array<string, string[]> Route prefix to allowed frame-ancestors origins
*/
private const ROUTE_ALLOWLIST = [
'widget/pricecompare' => ['https://partner-portal.example'],
'checkout/onepage' => ["'self'", 'https://payment-widget.example'],
];
/**
* @param HttpRequest $request Current HTTP request, used to resolve the route.
*/
public function __construct(private readonly HttpRequest $request)
{
}
/**
* Adds X-Frame-Options and CSP frame-ancestors headers before the response is sent.
*
* @param Http $subject The response object being dispatched.
* @return void
*/
public function beforeSendResponse(Http $subject): void
{
$path = ltrim((string) $this->request->getPathInfo(), '/');
$allowedOrigins = ["'self'"];
foreach (self::ROUTE_ALLOWLIST as $prefix => $origins) {
if (str_starts_with($path, $prefix)) {
$allowedOrigins = $origins;
break;
}
}
$subject->setHeader(
'Content-Security-Policy',
'frame-ancestors ' . implode(' ', $allowedOrigins),
true
);
// Legacy fallback: only DENY/SAMEORIGIN are valid single values
$subject->setHeader(
'X-Frame-Options',
count($allowedOrigins) === 1 && $allowedOrigins[0] === "'self'" ? 'SAMEORIGIN' : 'DENY',
true
);
}
}
9. Testing whether a page is vulnerable to clickjacking
The fastest test is a minimal HTML test file that embeds the target page in an iframe and opens locally in a browser. If the target page renders visibly inside the frame, the protection headers are missing or misconfigured. If the browser refuses to render it with a console message like "Refused to display '...' in a frame because it set 'X-Frame-Options' to 'deny'", the protection is working as expected. This test should be run separately for every sensitive route, not just the homepage, since header configuration can vary by cache rule or reverse proxy.
For automated checks, curl -I against the live production URL is useful for inspecting the actually delivered response headers, alongside online scanners like Mozilla Observatory's security header checks or securityheaders.com, which also flag missing X-Content-Type-Options and other headers. In CI/CD pipelines, a simple header-assertion test can be integrated that checks, on every deployment, whether X-Frame-Options and Content-Security-Policy: frame-ancestors are present on all critical routes, before a regression reaches production unnoticed.
<!-- clickjack-test.html: local test page to check if a target is framable -->
<!-- Open this file directly in a browser (file://) and observe the console -->
<!DOCTYPE html>
<html lang="en">
<head><title>Clickjacking test</title></head>
<body>
<h1>If the page below renders, it lacks clickjacking protection</h1>
<iframe src="https://shop.mironsoft.de/customer/account/"
sandbox="allow-scripts allow-same-origin"
width="800" height="600">
</iframe>
</body>
</html>
| Approach | Enforcement | Multiple origins | Verdict |
|---|---|---|---|
| JS frame-busting | Client, after load | No | Insecure, easily bypassed |
| X-Frame-Options: DENY | Server, before rendering | No | Secure, but rigid |
| X-Frame-Options: SAMEORIGIN | Server, before rendering | No | Secure for same-origin |
| CSP frame-ancestors | Server, before rendering | Yes, origin list | Recommended, modern standard |
| Both headers combined | Server, before rendering | Yes, plus legacy fallback | Best practice, defense in depth |
The table makes the central difference clear: only server-set headers are evaluated by the browser before the page becomes visible at all, while JavaScript running in the frame context is always subject to the attacker's control. Anyone securing a new project today should start directly with frame-ancestors and add X-Frame-Options purely as a compatibility fallback, never as the sole protection.
Mironsoft
Security headers, CSP configuration, and hardening for Magento stores
Is your store protected against clickjacking?
We audit your live response headers, identify missing or misconfigured X-Frame-Options and CSP directives, and implement a clean frame-ancestors allowlist for every legitimate embedding case.
Security header audit
Full review of every route for X-Frame-Options and CSP
CSP implementation
frame-ancestors, script-src, and other directives for Magento and Hyvä
Embedding allowlist
Clean exceptions for payment widgets, partners, and admin iframes
10. Summary
Clickjacking protection for Magento stores addresses a clearly scoped problem: an invisible iframe layered over a real page must never be able to redirect a user's clicks onto hidden, critical actions. JavaScript frame-busting, no matter how sophisticated, runs within the attacker's sphere of control and is therefore inherently bypassable, whether through the sandbox attribute, header-stripping proxies, or nested double framing. Reliable protection comes exclusively from the server: X-Frame-Options with DENY or SAMEORIGIN for simple cases, and Content-Security-Policy: frame-ancestors with an explicit origin list for every case where multiple specific partner domains need to embed the page.
In practice, combining both headers as a defense-in-depth strategy proves effective, complemented by a carefully maintained allowlist for legitimate use cases such as payment widgets, partner embeds, or internal admin iframes. Regular testing with a minimal iframe test page or automated header checks in the CI pipeline ensures that a deployment, a new reverse proxy, or a cache rule doesn't accidentally undo protection that had worked reliably for months.
Clickjacking Protection for Magento Stores - The Essentials at a Glance
Avoid JS frame-busting
Client-side scripts like if (top != self) can be bypassed via sandboxed iframes and proxy stripping. Not reliable protection.
Set X-Frame-Options
DENY or SAMEORIGIN as a legacy fallback, but with no support for multiple origins.
Use CSP frame-ancestors
Modern standard with an explicit origin list, fully replaces X-Frame-Options functionally.
Combine both headers
Defense in depth for maximum browser compatibility, plus a maintained allowlist for legitimate embeds.