testiframe.com

Clickjacking Protection: How to Stop Your Site Being Framed

Clickjacking tricks your users into clicking your own buttons through an invisible frame. Here is how the attack works, which defences actually stop it, and copy-paste header config for every common server, framework and host.

Published · 10 min read
Quick answer

Clickjacking protection means telling browsers who may frame your pages. Send Content-Security-Policy: frame-ancestors 'none' (or 'self') on every HTML response, plus X-Frame-Options: DENY (or SAMEORIGIN) for older browsers. Both must be HTTP headers, not meta tags. Add SameSite cookies and confirmation steps for sensitive actions as defence in depth.

What is clickjacking?

Clickjacking, also called UI redressing, is an attack where another site loads your page in an iframe, makes that frame invisible, and positions it over a decoy. The visitor thinks they are clicking "Play" or "Claim prize" on the attacker's page. The click actually lands on a real button in your page, with the visitor's real session.

Nothing is stolen from your server and no code is injected. The browser simply delivers a legitimate click to a legitimate page. That is why the fix lives in the browser too: you send a response header that says which sites, if any, may put your page in a frame.

A clickjacking attack example (for testing your own site)

The whole trick is a few lines of CSS. The page below stacks a transparent iframe on top of a decoy button. Point it only at a page on your own site, ideally on localhost or staging, to see whether your protection works.

<!doctype html>
<title>Clickjacking overlay demo (your own site only)</title>
<style>
  .stage  { position: relative; width: 500px; height: 400px; }
  .decoy  { position: absolute; top: 180px; left: 60px; padding: 12px 24px; }
  .target {
    position: absolute; inset: 0;
    width: 500px; height: 400px; border: 0;
    opacity: 0;   /* set to 0.4 while testing to see the alignment */
    z-index: 2;   /* the frame sits on top, so it receives the click */
  }
</style>
<div class="stage">
  <button class="decoy">Claim your prize</button>
  <iframe class="target" src="https://staging.your-site.example/account/settings"></iframe>
</div>

If your settings page loads inside the frame, a real attacker could line up one of its buttons with the decoy. If the frame stays empty and the console shows an X-Frame-Options or frame-ancestors error, you are protected. In Chrome the blocked frame shows the familiar "refused to connect" page, covered in detail in why iframes refuse to connect.

What can a clickjacking attack actually do?

Clickjacking only works for actions that complete in one or two clicks without typing. That still covers a lot:

  • Settings changes: toggling a profile to public, disabling a security notification, adding a forwarding address, or changing a privacy setting.
  • Purchases and payments: one-click buy buttons, subscription upgrades, or donations using a saved payment method.
  • Permission grants: an OAuth "Allow" screen, sharing a document with an attacker's account, or accepting an invitation into a workspace.
  • Social actions: follows, likes, votes and posts that spread the attack or manipulate rankings.
  • Destructive actions: deleting content or leaving an organisation when there is no second confirmation.

Attacks that need typed input are much harder, and cross-origin rules stop the attacker reading anything in your page. The risk is concentrated on logged-in pages with single-click, state-changing buttons.

How to prevent clickjacking: the defences, ranked

RankDefenceWhat it doesLimits
1CSP frame-ancestorsTells the browser which origins may frame the page: 'none', 'self', or a list of origins with wildcards.Header only; ignored in <meta> and in Report-Only policies; doesn't fall back to default-src.
2X-Frame-OptionsDENY blocks all framing, SAMEORIGIN allows only your own origin.No allowlist (ALLOW-FROM is obsolete and ignored). Ignored by modern browsers when frame-ancestors is present.
3SameSite cookiesLax or Strict cookies aren't sent in cross-site frames, so the framed page loads logged out.No help against same-site attackers, SameSite=None cookies, or pages that need no login.
4Confirmation stepsRe-authentication, typed confirmation or a second screen for high-value actions.Friction; only applied to the actions you pick.

Use the first two together. Every ancestor in the frame chain must match frame-ancestors, not just the direct parent, so an allowed partner can't be used as a stepping stone by a page that frames them. The recommended pairing is frame-ancestors 'none' with X-Frame-Options: DENY, or 'self' with SAMEORIGIN. For a full comparison of the two headers, see X-Frame-Options vs CSP frame-ancestors.

SameSite is a strong extra layer. Chrome and Edge treat cookies without a SameSite attribute as Lax, so they aren't sent in cross-site iframes, but any cookie you mark SameSite=None; Secure is still sent where the browser allows third-party cookies. Our guide to iframe cookie behaviour covers the per-browser details.

Why JavaScript frame-busting isn't enough

Before these headers existed, sites used scripts like this:

if (window.top !== window.self) {
  window.top.location = window.self.location;
}

It fails in several ways. The attacker can frame your page with the sandbox attribute and leave out allow-top-navigation, so the redirect is blocked while forms still work. They can leave out allow-scripts too, so your busting code never runs. Our sandbox attribute guide explains how each token changes this. The script also depends on loading before the user clicks.

Don't rely on frame-busting scripts

A script is at best a fallback for very old browsers. The header is enforced by the browser before your page renders, and the framing site can't switch it off.

Is your site protected right now?

Paste a page URL to see whether it can be framed and which header, if any, is blocking it.

How to add anti-clickjacking headers on every stack

Each example blocks all framing. Swap 'none' for 'self' and DENY for SAMEORIGIN if your own pages frame each other. If you already send a Content-Security-Policy header, add the frame-ancestors directive to it rather than sending a second policy.

Nginx

add_header Content-Security-Policy "frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;

always adds the headers to error responses too. By default, any add_header inside a location block replaces the ones inherited from server, so repeat them there.

Apache

# .htaccess or VirtualHost, requires mod_headers
Header always set Content-Security-Policy "frame-ancestors 'none'"
Header always set X-Frame-Options "DENY"

IIS

<!-- web.config -->
<system.webServer>
  <httpProtocol>
    <customHeaders>
      <add name="Content-Security-Policy" value="frame-ancestors 'none'" />
      <add name="X-Frame-Options" value="DENY" />
    </customHeaders>
  </httpProtocol>
</system.webServer>

Express with helmet

Plain app.use(helmet()) already sends frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN. To block all framing:

const express = require('express');
const helmet = require('helmet');
const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: { frameAncestors: ["'none'"] },
    },
    // the X-Frame-Options middleware, called frameguard in older Helmet versions
    xFrameOptions: { action: 'deny' },
  })
);

Next.js

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
          { key: 'X-Frame-Options', value: 'DENY' },
        ],
      },
    ];
  },
};

Django

XFrameOptionsMiddleware is in the default project template and sends DENY by default in modern Django. Add frame-ancestors through your CSP setup (recent Django versions include CSP support; older projects commonly use the django-csp package).

# settings.py
MIDDLEWARE = [
    # ...
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
X_FRAME_OPTIONS = "DENY"

Rails

Rails' default headers already include X-Frame-Options: SAMEORIGIN.

# config/initializers/content_security_policy.rb
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.frame_ancestors :none
  end
end

# config/application.rb: tighten the default SAMEORIGIN
config.action_dispatch.default_headers["X-Frame-Options"] = "DENY"

Laravel

<?php
// app/Http/Middleware/AntiClickjacking.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class AntiClickjacking
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);
        $response->headers->set('Content-Security-Policy', "frame-ancestors 'none'");
        $response->headers->set('X-Frame-Options', 'DENY');
        return $response;
    }
}

Register it globally: in Laravel 11 and later with $middleware->append(AntiClickjacking::class) in bootstrap/app.php, or in the $middleware array of app/Http/Kernel.php on older versions.

Spring Security

Spring Security sends X-Frame-Options: DENY by default. Make it explicit and add CSP:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.headers(headers -> headers
        .frameOptions(frame -> frame.deny())
        .contentSecurityPolicy(csp -> csp.policyDirectives("frame-ancestors 'none'"))
    );
    // ...the rest of your security configuration
    return http.build();
}

Cloudflare Pages and Netlify (_headers)

/*
  Content-Security-Policy: frame-ancestors 'none'
  X-Frame-Options: DENY

For a site proxied through Cloudflare rather than hosted on Pages, a response header Transform Rule can add the same headers at the edge.

Vercel

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" },
        { "key": "X-Frame-Options", "value": "DENY" }
      ]
    }
  ]
}
Set the headers in one layer

If your app and your proxy both add X-Frame-Options, browsers see conflicting or duplicate values and treat them as DENY. That breaks pages you meant to allow. Pick one layer, usually the edge or the web server.

How to keep widgets embeddable and block everything else

Booking widgets, video players, status badges and checkout embeds exist to be framed. Don't loosen the whole site for them. Scope a different policy to the embeddable routes and keep the strict default everywhere else.

server {
    # Default: nobody may frame any page
    add_header Content-Security-Policy "frame-ancestors 'none'" always;
    add_header X-Frame-Options "DENY" always;

    location /embed/ {
        # This replaces the server-level headers for /embed/ only.
        # No X-Frame-Options here: it has no allowlist.
        add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example https://*.customer.example" always;
        proxy_pass http://127.0.0.1:3000;
    }
}

The same idea in Express, placed after helmet so it overrides the global policy on those routes (it replaces the whole CSP header, so include any other directives the widget needs):

app.use('/embed', (req, res, next) => {
  res.removeHeader('X-Frame-Options');
  res.setHeader('Content-Security-Policy', "frame-ancestors 'self' https://partner.example");
  next();
});

In Django, decorate the view with @xframe_options_exempt and set its frame-ancestors allowlist; in Rails, override content_security_policy in that controller and delete the X-Frame-Options header. For how partners should then embed it, see how to embed a website in HTML.

Keep embeddable pages low-risk

An embeddable route can be clickjacked by any origin you allow. Keep sensitive one-click actions off those pages, or require a confirmation step in a new window.

How to test your clickjacking protection

Start with the raw headers:

curl -sI https://your-site.example/account/settings | grep -iE 'content-security-policy|x-frame-options'

Then check behaviour in a real browser. Paste the page into the testiframe.com iframe tester (or deep link it, e.g. https://testiframe.com/?url=https%3A%2F%2Fyour-site.example). The report names the header that blocks framing (X-Frame-Options or frame-ancestors) and flags possible frame-busting scripts, so you can confirm the header, not a script, is doing the work. For widget routes, use the "Would it load on your site?" check with a partner's origin to confirm the allowlist accepts it, and a random origin to confirm it's refused. The homepage section on preventing your site from being embedded has a quick summary.

Test several URL types: the homepage, a logged-in settings page, an error page and a redirect. Missing headers on 404s and redirects are common because some servers only add them to successful responses.

How to fix a "Missing anti-clickjacking header" finding

Automated scanners and pentest reports flag this constantly. OWASP ZAP reports it as "Missing Anti-clickjacking Header", and other tools use similar wording. To close the finding:

  1. Add both frame-ancestors and X-Frame-Options using the config above, at a layer that covers every response, including errors.
  2. Remove any <meta http-equiv> versions. Browsers ignore them, and they suggest protection that isn't there.
  3. Replace ALLOW-FROM. It's obsolete; Chrome never supported it and Firefox dropped it in version 70. Use a frame-ancestors allowlist instead.
  4. Check that frame-ancestors is in the enforced Content-Security-Policy header, not only in Content-Security-Policy-Report-Only.
  5. For intentionally embeddable routes, document the scoped policy so reviewers can mark the finding as accepted rather than open.

Scanners often flag JSON endpoints, images and static files too. The real risk is on HTML pages with clickable actions, but sending the headers globally costs nothing and keeps the report clean.

FAQ

Do I still need X-Frame-Options if I use CSP frame-ancestors?

Modern browsers ignore X-Frame-Options when an enforced CSP contains frame-ancestors, so frame-ancestors does the real work. Sending X-Frame-Options as well is still recommended because it covers older browsers and satisfies security scanners. Pair frame-ancestors 'none' with DENY, or 'self' with SAMEORIGIN.

Can I set X-Frame-Options or frame-ancestors in a meta tag?

No. Browsers ignore X-Frame-Options in a <meta http-equiv> tag, and frame-ancestors is ignored when CSP is delivered in a meta tag. Both must be sent as HTTP response headers by your server, framework, CDN or hosting platform.

Does X-Frame-Options ALLOW-FROM still work?

No. ALLOW-FROM is obsolete and modern browsers ignore it. Chrome never supported it and Firefox dropped it in version 70. To allow specific sites to embed a page, use Content-Security-Policy: frame-ancestors with a list of origins.

Does SameSite=Lax prevent clickjacking?

Partly. Lax and Strict cookies are not sent to your site inside a cross-site iframe, so an attacker's frame usually loads a logged-out page with nothing to hijack. It does not help against same-site attackers or cookies set to SameSite=None, and it does not protect pages that need no login, so treat it as defence in depth, not a replacement for frame-ancestors.

Should API endpoints send anti-clickjacking headers?

JSON API responses are not rendered as clickable pages, so the practical risk is low. Setting the headers globally is cheap, keeps scanner reports clean, and avoids gaps when a route starts returning HTML later, so most teams simply send them on every response.

How do I allow only specific sites to embed my page?

Send Content-Security-Policy: frame-ancestors followed by the allowed origins, for example frame-ancestors 'self' https://partner.example https://*.customer.example. Do not send X-Frame-Options DENY or SAMEORIGIN on that route, and scope the policy to the embeddable URLs only.