Clickjacking and iframes
Iframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
A click you did not mean to make
Picture Priya, who is logged in to her bank in one tab. In another tab she opens a page that promises a free phone, with a big green Claim prize button. She clicks it. A second later her bank account settings change, and she never saw the bank at all.
Nothing was hacked in the usual sense. No password leaked, no script ran on the bank's site, no request was forged. Priya really did click a real button on the real bank page, with her real session. She just could not see it, because the prize page had loaded the bank inside an invisible frame and lined up the bank's button exactly under her cursor.
This is clickjacking: an attacker page puts your page in an iframe, hides or disguises it, and tricks the user into clicking, typing, or dragging inside it. Another name for it is UI redressing, because the attacker dresses your interface up as something else.
Why your other defenses do not help
Clickjacking slips past defenses that stop other attacks. That is what makes it worth a lesson of its own.
- CSRF tokens do not help. A CSRF token, a secret value the server puts in its own forms (see the CSRF lesson), stops other sites from sending forged requests. Here the request is not forged: it comes from your own page, with your own token, because the user clicked your own button.
- SameSite cookies only help sometimes. A cookie with
SameSite=LaxorStrictis not sent when your page is framed by another site, so the framed page loads logged out. That blocks many attacks today, but a cookie set withSameSite=None(common for embeds and single sign-on) is still sent, and logged-out actions can still be abused. - XSS defenses and CSP script rules do not help. The attacker runs no code on your origin. Their script runs on their own page, which they fully control.
- The same-origin policy does not help. It stops the attacker's script from reading your frame. It does not stop the attacker from showing your frame, placing it, and making it see-through.
The fix has to be different in kind: your page has to tell the browser who is allowed to put it in a frame at all. That is the job of the CSP frame-ancestors directive and the older X-Frame-Options header.
Iframes cut both ways
An iframe (inline frame) is an element that shows a whole separate document inside your page. Frames are not bad. They are how a YouTube video, a Stripe card form, a Google Map, a chat widget, and a code playground all appear inside other sites. Most real products both embed other people's content and get embedded by others.
So this lesson covers both directions. When you are the one being framed, you decide who may frame you and you defend against clickjacking. When you are the one embedding, you decide what the framed content may do to you and your users: run scripts, open popups, navigate your tab, use the camera, or send you messages. The tools are different for each direction, and mixing them up is the most common mistake.
| Question | You are framed (the child) | You embed (the parent) |
|---|---|---|
| Main risk | Clickjacking: your buttons get clicked under a disguise | Untrusted content attacks your page or your users |
| Main tool | CSP frame-ancestors, plus X-Frame-Options | The sandbox and allow attributes on the iframe |
| Where it is set | A response header on your page | Markup on the embedding page |
| Talking across | postMessage, checking who sent it | postMessage, checking who sent it |
Check your understanding · Exercise 1
A bank page uses CSRF tokens on every form and has a strict script CSP, but no framing headers. An attacker frames it at opacity 0 over a fake button. Which statements are true? Select all that apply.
The mental model: a window with two locks
Think of an iframe as a window cut into a wall between two flats. The flat that owns the wall (the parent page) decides how big the window is, where it goes, and whether it is painted over. The flat on the other side (the framed page) decides what it shows through the window and whether it agrees to be seen at all.
Each side has its own lock. The framed page's lock is a response header: if it says no, the browser never shows the page in that window. The parent's lock is a set of attributes on the iframe tag: they limit what the framed page may do once it is shown. Neither side can open the other side's lock.
sandbox, allow). The framed page owns its content and the frame-ancestors header. The only door between them is postMessage.What the parent controls
The parent page owns the iframe element, so it owns everything about the box: size, position, z-index (the stacking order), opacity, CSS transforms, filters, and what sits on top. This is the power the attacker uses. With opacity: 0 the frame is invisible but still clickable. With pointer-events: none on a decoy laid over the frame, the decoy is visible but clicks pass through it to the frame below.
The parent also controls the frame's powers through attributes. sandbox turns off scripts, forms, popups, and more. allow decides which browser features (camera, microphone, geolocation, fullscreen, payment) the frame may ask for. referrerpolicy, loading, and credentialless tune the rest.
What the framed page controls
The framed page controls its own content and, through headers, whether it may be framed and by whom. It cannot see how it is styled from outside. It cannot read the parent's DOM if the two are cross-origin, and the parent cannot read its DOM either. That is the same-origin policy: two documents can only touch each other's DOM and storage if their scheme, host, and port all match.
With site isolation (see the browser architecture lesson), a cross-site frame runs in a different renderer process from its parent in desktop Chrome and Firefox. That protects memory against attacks like Spectre. It does not change the picture above: the parent still decides where the frame is drawn, and the frame is still painted into the same screen.
What crosses the wall
Cross-origin frames cannot touch each other, but a few things still cross the wall. Each is a place where bugs live, and each gets its own chapter later.
- Pixels and clicks. The user sees one screen and has one cursor. The parent decides what is on top. This is the clickjacking channel.
- Messages.
window.postMessagelets either side send structured data to the other. The receiver must check who sent it. - Navigation. A frame can try to navigate the top window (
window.top.location = ...), and the parent can navigate the frame. The sandbox decides whether the first is allowed. - Cookies and storage. A cross-site frame may or may not get its cookies, depending on
SameSite, third-party cookie rules, and storage partitioning. - Small leaks. The parent can see when a frame finished loading and how many frames it contains (
frame.contentWindow.length). These side channels matter mostly for privacy attacks.
Think first
Your product is a payment form that shops embed. Which of these do you set, and which does the shop set: the frame-ancestors list, the sandbox attribute, the allow="payment" attribute, and the message origin check in your frame?
You set frame-ancestors on your form's response, because only the framed page can say who may frame it. A real payment provider lists the shop origins that registered, or allows any HTTPS origin and protects the risky steps in other ways.
The shop sets sandbox and allow="payment" on its iframe tag, because those are attributes of the parent's element. If the shop sandboxes too hard, your form breaks, so your docs must say which tokens you need.
You write the origin check in your frame's message handler, and the shop writes the matching check in its own handler. Each side checks the other.
How the attack works, step by step
A clickjacking page needs no exploit and no special browser bug. It uses ordinary HTML and CSS that every page is allowed to use. That is why it works in every browser unless the target page says no.
Building the overlay
The attacker needs three things: your page must load in a frame, the user must be logged in inside it, and the target button must sit exactly where the user will click. Here is the whole attack page, cut down to the parts that matter.
<style>
.bait { position: absolute; top: 300px; left: 120px; }
.target {
position: absolute; top: 0; left: 0;
width: 800px; height: 600px; border: 0;
opacity: 0; /* invisible ... */
z-index: 2; /* ... but on top, so it gets the click */
transform: translate(-212px, -88px); /* line up the real button */
}
</style>
<button class="bait">Claim prize</button>
<iframe class="target" src="https://bank.example/settings/delete"></iframe>
The attacker finds the offset by loading your page in their own browser and measuring. If your layout changes with screen width, they set the frame to a fixed width so your responsive layout lands in a known place. If your button is low on the page, they scroll the frame with a URL fragment such as #delete-section or simply make the frame tall and move it up.
Building a clickjacking overlayStep 1 of 5: The bait page
evil.example shows a prize and a Claim prize button. This is all the user ever sees.The bait page: The attacker's page on evil.example shows a prize and a Claim prize button. This is all the user ever sees.
The family of variants
The classic attack hides your page. The variants keep the same idea, a real action inside a disguise, and change how the disguise works.
- Decoy on top. Instead of an invisible frame, the attacker shows the frame normally but covers most of it with their own images, leaving only your button showing through a hole. Or they put a picture over your button with
pointer-events: none, so the picture is seen but the click passes through to your button. - Cursorjacking. The attacker hides the real cursor with
cursor: noneand draws a fake cursor image a few dozen pixels away. The user aims the fake cursor at a harmless button while the real, invisible pointer sits over your button. - Multi-step clicks. A game asks the user to click several targets in a row: first open a menu, then pick a setting, then confirm. Each click lands on a different part of your page. Confirmation dialogs inside the frame do not help, because the next click is already planned for them.
- Keystroke and drag jacking. If a text field inside the frame has focus, the user's typing goes there. Old attacks also used drag and drop to pull text out of a frame or drop attacker text into it. Browsers have since restricted cross-origin drag and drop, but focus stealing still matters for forms.
- Likejacking and permission jacking. The best-known real case was Facebook Like buttons around 2010: a hidden Like button under a video play button. The same idea targets the browser's own prompts, for example tricking a click on Allow for camera or notifications.
Double-clickjacking: framing without a frame
In December 2024 the researcher Paulos Yibelo described a variant he called double-clickjacking. It does not need a frame at all, so frame-ancestors cannot stop it. It uses the small gap between the two clicks of a double click.
- The attacker page opens a new window, often when the user clicks a harmless button, that says something like "double click to prove you are human". The new window covers the original tab.
- Behind it, the new window uses its
window.openerlink to send the original tab to your real page, for example an OAuth consent screen (the page that asks "let this app access your account?") or an account setting. It loads there, out of sight. - On the first click (in practice on
mousedown, the moment the button goes down), the new window closes itself. The original tab, now showing your real page, is under the cursor. - The second click of the double click lands on your real Allow or Confirm button, at full opacity, in the top window.
window.opener while the popup covers it. The popup closes on the first click, so the second click hits the real button.The defence lives in your page, not in a header. The write-up's fix keeps sensitive buttons disabled until the page sees a real gesture on it, such as a mouse move or a Tab key press. For high-impact actions you can also ask for something a planned click cannot hit, such as typing a value.
Treat the details as still evolving. The write-up suggested a new browser header for this, but no standard header covers it yet.
Check your understanding · Exercise 2
Your account settings page sends frame-ancestors 'self'. Which attacks can still trick a user into clicking its Delete button? Select all that apply.
Saying no to frames: X-Frame-Options and frame-ancestors
The real fix for clickjacking is a response header on your page that tells the browser who may show it in a frame. There are two such headers. One is old and simple, the other is newer and more precise. Most sites should send both, with the same meaning.
X-Frame-Options, the old header
X-Frame-Options was added by Internet Explorer 8 in 2009 as a quick answer to clickjacking, and every browser copied it. It was only written into a standard later (RFC 7034 in 2013, and the HTML standard now defines exactly how browsers process it). It has two useful values.
DENY: no page may frame this one, not even a page from the same origin.SAMEORIGIN: only pages from the same origin may frame it. Modern browsers check every ancestor up to the top window, not only the direct parent, so a same-origin frame inside an attacker's frame is still blocked.ALLOW-FROM https://partner.example: obsolete. It was never supported in Chrome or Safari, and Firefox dropped it in version 70. A browser that does not understand the value ignores the whole header, which means no protection at all. If you need an allowlist, useframe-ancestors.
CSP frame-ancestors, the modern header
frame-ancestors is a directive of Content Security Policy (CSP, covered in its own lesson). It takes a list of sources, like other CSP directives, and the browser checks that every ancestor of the frame matches one of them: the parent, the grandparent, and so on up to the top window.
frame-ancestors carries the real list; X-Frame-Options is the fallback, and ALLOW-FROM silently turns the protection off.| Value | Meaning | Typical use |
|---|---|---|
'none' | Never framed, by anyone | Login, settings, admin, checkout |
'self' | Only by pages on the same origin | Apps that frame their own pages, such as a preview pane |
'self' https://shop.example | Same origin plus listed origins | A widget made for a few known partners |
https://*.partner.example | Any subdomain of one site | A partner with many storefront hosts |
https: | Any HTTPS page | Public embeds such as video players; risky actions need other defences |
Three details trip people up. First, frame-ancestors does not fall back to default-src, so a strict default-src 'self' alone does not stop framing. Second, it is ignored in a meta tag: it only works as a real HTTP response header (so does X-Frame-Options). Third, 'self' means exactly the origin of the framed page, so if your page on https://app.example is framed by https://www.app.example, that origin must be listed too.
When both headers are present
The HTML standard settles it: if the response has an enforced CSP with a frame-ancestors directive, the browser ignores X-Frame-Options completely. A Content-Security-Policy-Report-Only header does not count, so in report-only mode the old header still does the blocking. Old browsers that do not know frame-ancestors (Internet Explorer, very old Safari) use X-Frame-Options instead.
frame-ancestors short-circuits everything on the right. X-Frame-Options is only consulted when it is absent, and a value the browser does not understand counts as no header.Decision Which framing header should a typical web app send?
What we pick: Send both. Pick frame-ancestors as the source of truth and set X-Frame-Options to the closest match (DENY for 'none', SAMEORIGIN for anything else). For pages that partners embed, frame-ancestors carries the allowlist and X-Frame-Options is left off for those routes only.
Why frame-busting scripts are not enough
Before these headers, sites used frame-busting JavaScript: if the page is not the top window, move the top window to this page. It looks like this, and it is still worth knowing why it fails.
// The classic frame buster. Do not rely on it.
if (window.top !== window.self) {
window.top.location = window.self.location;
}
- The attacker sandboxes the frame.
sandbox="allow-scripts allow-forms allow-same-origin"withoutallow-top-navigationkeeps your page working as normal but blocks the top navigation. Your buster fails silently while your buttons still work. (allow-same-originis harmless to the attacker here, because your page is cross-origin to theirs.) - The attacker blocks the navigation. Old tricks used a
beforeunloadprompt on the parent, or quick repeated navigations that cancel yours. - The browser blocks it. Since Chrome 68 (2018), Chromium browsers stop a cross-origin frame from navigating the top window unless the user has interacted with that frame, so a buster that runs on load often fails even without a sandbox.
- Your script never runs. Scripts can be off, can fail to load, or can be delayed while the user is already clicking. A header is checked before a single byte of your page is drawn.
A 2010 Stanford study (Rydstedt, Bursztein, Boneh, and Jackson) looked at the frame busters used by the top 500 sites and showed that all of them could be defeated. If you must support a browser without either header, the safer script pattern hides the page by default (html { display: none }) and shows it only after confirming window.top === window.self. Today that is a fallback at most, never the main defence.
Check your understanding · Exercise 3
A page sends X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors https://shop.example. A page on shop.example frames it in a current Chrome. What happens, and why? Select all that apply.
Embedding untrusted content with sandbox
Now turn around. You are the parent, and you need to show content you do not trust: an HTML email, a user's code playground, an ad, a preview of AI-generated HTML, or a document a customer uploaded. You want it to render, maybe even run its scripts, without touching your page, your cookies, or your users.
The sandbox attribute is the tool. An iframe with an empty sandbox attribute gets every restriction at once. Each token you add gives back exactly one power. It is an allowlist, so the safe default is to start empty and add only what the content needs.
What an empty sandbox takes away
- Its origin. The document gets an opaque origin, a fresh origin that matches nothing, not even itself on reload. It cannot read cookies or storage of any real origin, and its requests send
Origin: null. - Scripts. No JavaScript runs, including inline handlers and
javascript:links. - Forms. Forms cannot submit.
- Popups and dialogs. No
window.open, notarget="_blank"links, noalertorconfirm. - Navigating you. It cannot change the top window's URL, which also blocks frame busting.
- Other powers. Downloads, pointer lock, presentation, and screen orientation lock are off.
allow-scripts allow-same-origin on your own origin lets the content remove its sandbox, so it ends up with everything.The tokens that matter
| Token | Gives back | Watch out for |
|---|---|---|
allow-scripts | Running JavaScript | Safe alone: the scripts run in an opaque origin |
allow-same-origin | The content's real origin, with its cookies and storage | Never combine with allow-scripts for content on your origin |
allow-forms | Submitting forms | Phishing forms can now post anywhere |
allow-popups | Opening new windows | Popups inherit the sandbox unless you also add allow-popups-to-escape-sandbox |
allow-modals | alert, confirm, print | Dialog spam over your page |
allow-top-navigation-by-user-activation | Navigating your tab, but only right after a click | Prefer this to plain allow-top-navigation |
allow-downloads | Starting downloads | Drive-by downloads from ads |
The one combination that escapes
allow-scripts allow-same-origin together is the famous trap. If the framed content is served from your own origin, its scripts now run as your origin. Your origin is allowed to touch the parent page, so the script can find its own iframe element in the parent, remove the sandbox attribute, and reload itself with no sandbox at all. It can also just read your cookies and localStorage directly.
// Runs inside <iframe sandbox="allow-scripts allow-same-origin"
// src="https://app.example/preview/42">
const me = window.parent.document.querySelector("iframe");
me.removeAttribute("sandbox"); // the parent page is same-origin, so this works
location.reload(); // the sandbox is gone after this navigation
console.log(document.cookie); // or skip all that and read app.example cookies
The same two tokens are fine when the content comes from a different site that holds nothing of yours. That is why code playgrounds and user-content hosts serve untrusted pages from a separate domain: GitHub serves raw files from githubusercontent.com, and Google uses googleusercontent.com. A separate registrable domain (a different site, not just a subdomain) matters, because subdomains can share cookies set with a Domain attribute and count as same-site for SameSite rules.
<iframe sandbox="allow-scripts"
src="https://app.example/preview/42"></iframe>
srcdoc: inline HTML without a URL
The srcdoc attribute holds the frame's HTML directly, so you do not need a URL or a second server. It is handy for previews and emails. The trap: a srcdoc frame inherits the parent's origin unless it is sandboxed. Untrusted HTML in an unsandboxed srcdoc is the same as pasting it into your page, which is a stored XSS bug (see the XSS lesson).
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts"); // never allow-same-origin here
frame.referrerPolicy = "no-referrer";
frame.srcdoc = untrustedHtml; // a property, so no attribute quoting bugs
container.append(frame);
If you serve untrusted files yourself (user uploads, attachments), you can also make the server force a sandbox on them with the CSP sandbox directive, for example Content-Security-Policy: sandbox allow-scripts. That protects people who open the file URL directly in a tab, where no iframe attribute exists.
Credentialless iframes
A newer attribute, credentialless, loads the frame in a fresh, temporary storage context: no existing cookies are sent and nothing it stores outlives the page. It was built so that sites using cross-origin isolation (the headers that unlock SharedArrayBuffer) can still embed third-party pages that do not opt in. As of 2025 it ships in Chromium browsers only, so treat it as an extra layer, not a replacement for sandbox.
Decision How should a product show untrusted HTML, such as user-written pages or AI-generated previews?
What we pick: For static, non-interactive content such as rich text, sanitize. For anything that runs scripts, use a frame: a sandboxed srcdoc for simple previews, and a separate site (still sandboxed) when the content needs a real origin. Never run untrusted scripts on your own origin.
Check your understanding · Exercise 4
You build an AI chat product that renders generated HTML apps in a preview pane. Which setups are safe? Select all that apply.
Talking across the wall with postMessage
Cross-origin frames cannot touch each other's DOM, but real embeds need to talk. A payment frame must tell the shop that payment succeeded, a video player must tell the page the video ended, and a chat widget must ask the page to resize its frame. The one safe channel for this is window.postMessage.
How it works
The sender calls otherWindow.postMessage(data, targetOrigin). The browser copies data with the structured clone algorithm (so objects, arrays, dates, and Blobs work, but functions and DOM nodes do not) and fires a message event on the other window. The event carries three facts the receiver must use: event.data (the copy), event.origin (the sender's origin, set by the browser and impossible to fake), and event.source (a handle to the sender's window).
event.origin when it receives, and still treats "paid" as a claim to confirm with its own server.Three rules, one for each side and one for the data
- The receiver checks
event.originagainst an exact value. Any window can send you a message: a frame, a popup, or a page that opened yours. Compare with===against a known origin. Do not useincludes,startsWith, or a loose regex:https://pay.example.evil.comcontainspay.example. - The sender names the target origin.
postMessage(data, "https://shop.example")delivers only if the receiving window is still on that origin at delivery time. With"*", if the frame has been navigated somewhere else (by an attacker or a redirect), your data goes to whoever is there now. Use"*"only for data you would happily publish. - The receiver validates the data shape. Even a trusted origin can send something unexpected after a bug or an XSS on their side. Check the message type, check each field's type, and never pass message data to
innerHTML,eval,location, or a URL without checking it.
const PAY_ORIGIN = "https://pay.example";
const payFrame = document.querySelector<HTMLIFrameElement>("#pay")!;
window.addEventListener("message", (event) => {
if (event.origin !== PAY_ORIGIN) return; // 1. who sent it
if (event.source !== payFrame.contentWindow) return; // and from which window
const msg = event.data;
if (typeof msg !== "object" || msg === null) return; // 3. what it is
if (msg.type === "paid" && typeof msg.orderId === "string") {
showReceipt(msg.orderId); // still confirm with your server
}
});
// 2. when replying, name the receiver's origin, never "*"
payFrame.contentWindow!.postMessage({ type: "theme", dark: true }, PAY_ORIGIN);
Messages from sandboxed frames
A frame sandboxed without allow-same-origin has an opaque origin, so its messages arrive with event.origin === "null" (the string). Never allowlist "null": every sandboxed frame on the web, and every data: URL, has that origin. Instead check event.source === previewFrame.contentWindow, which only your own frame element can match. When you send to such a frame you have to use "*" as the target origin, so send it nothing secret.
A private line with MessageChannel
For long conversations, open a MessageChannel: it creates two linked ports. Send one port to the frame in a single, origin-checked postMessage (as the transfer list, the third argument). From then on, both sides talk over the ports, and no other window can listen or inject. Libraries such as Comlink build on this to make frames feel like async function calls.
const { port1, port2 } = new MessageChannel();
frame.contentWindow!.postMessage({ type: "connect" }, "https://widget.example", [port2]);
port1.onmessage = (event) => handleWidgetMessage(event.data); // only the widget holds port2
port1.postMessage({ type: "init", locale: "en-IN" });
What real bugs look like
Missing or loose origin checks are a common finding in bug bounty reports, because they are easy to miss in review. The common shapes:
- A widget listens for
{ type: "navigate", url }and setslocation.href = urlwith no origin check. An attacker frames the widget, sendsjavascript:or a phishing URL, and gets XSS or an open redirect. - An auth popup posts the access token back to
window.openerwith target origin"*". An attacker opens the auth URL from their own page and receives the token. - A resize handler trusts
event.data.heightand sets it as a style. A huge value pushes the page's real content off screen and can be used to hide or reveal parts of the page. - An origin check written as
event.origin.indexOf("example.com") > -1, which passes forexample.com.attacker.net.
Check your understanding · Exercise 5
Which message handlers are safe for a shop that embeds a frame from https://pay.example? Pick one.
Cookies, storage, and permissions across frames
Clickjacking and sandboxing are about attacks. This chapter is about the everyday rules that decide whether a legitimate embed works at all: whether a framed widget sees its cookies, what storage it gets, and which browser features it may use. These rules changed a lot between 2020 and 2025, and they explain most "works on my site, breaks when embedded" bugs.
Cookies in a cross-site frame
When shop.example frames widget.example, the frame's requests are cross-site. A cookie on widget.example is only sent with them if it was set with SameSite=None; Secure. The default in Chromium is Lax, so a cookie set without a SameSite value stays home, and the widget looks logged out. That same default is what blocks many clickjacking attacks today, which is a nice side effect.
On top of SameSite, browsers apply third-party cookie rules. Safari has blocked third-party cookies by default since 2020, and Firefox gives each top-level site its own cookie jar (Total Cookie Protection, on by default since 2022). Chrome planned to remove third-party cookies, then announced in 2024 and 2025 that it would keep them and not show a new prompt, so they still work there by default unless the user blocks them. Design embeds to work with no third-party cookies; that is the only behaviour you can count on everywhere.
Partitioned storage and cookies
Storage partitioning means a frame's storage is keyed by two things: the frame's own origin and the top-level site it is embedded in. localStorage, IndexedDB, Cache Storage, and service workers in a cross-site frame are partitioned in all three major engines. So the same widget on two shops gets two separate, empty storages and cannot use them to follow a user from shop to shop.
document.requestStorageAccess() is the supported way back to the first-party jar.Cookies can opt into the same model with the Partitioned attribute (the proposal was called CHIPS): Set-Cookie: sid=abc; Secure; SameSite=None; Partitioned; Path=/. The widget gets a separate cookie per embedding site, which keeps it working where unpartitioned third-party cookies are blocked. When a widget truly needs its first-party login (for example a comments widget that shows "logged in as Priya"), it calls document.requestStorageAccess() from a click inside the frame. The browser may ask the user, and on success the frame gets its unpartitioned cookies.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Security-Policy: frame-ancestors https://*.shop-partners.example
Set-Cookie: widget_sid=8f3a; Secure; HttpOnly; SameSite=None; Partitioned; Path=/
Permissions Policy and the allow attribute
Powerful features such as the camera, microphone, geolocation, fullscreen, the Payment Request API, and clipboard writes are off by default in cross-origin frames. The parent turns them on per frame with the allow attribute, and limits them for the whole page with the Permissions-Policy header.
<!-- A video call frame: camera, mic, fullscreen, nothing else -->
<iframe
src="https://meet.example/room/42"
title="Video call with support"
allow="camera; microphone; fullscreen; display-capture"
referrerpolicy="strict-origin-when-cross-origin"
loading="lazy"></iframe>
allow="camera"delegates the feature to the origin insrc. It is a ceiling, not a grant: the user is still asked for permission, and the prompt names the top-level site.- The parent's
Permissions-Policyheader wins over the attribute.Permissions-Policy: camera=()on your page means no frame can get the camera, whatever itsallowsays. - A good default header for an app that embeds third parties turns off what you never use:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self "https://pay.example"). - Every
iframeneeds atitlethat says what is inside, because screen readers announce frames by it.
Designing a page that others embed
If your product is the widget (a Like button, a payment field, a booking calendar), you must allow framing on that route, so frame-ancestors cannot be your clickjacking defence there. Defend in the design instead.
- Keep the framed surface small and low-risk. Anything sensitive (signing in, changing an account, approving a large payment) opens in a popup or a new top-level window, where your own headers protect it and the user sees your real URL.
- Allow framing only on the embed route, with a partner allowlist when you have one, and keep
frame-ancestors 'none'everywhere else. - For one-click actions, Chromium offers Intersection Observer v2 with
trackVisibility, which tells the frame whether it is actually visible and not covered or faded. It was designed so widgets such as social buttons and ads can ignore clicks while hidden. It is not in Firefox or Safari, so it is a bonus check. - Accept messages only from registered partner origins, and post replies only to the origin that registered.
Check your understanding · Exercise 6
Your comments widget, framed on many blogs, shows users as logged out in Safari and Firefox but logged in on Chrome. Which changes make sense? Select all that apply.
What breaks in production, and a lab
Most clickjacking findings in real audits are not clever. A header is missing on one route, two headers disagree, or a fix for an embed broke the protection everywhere. Here are the failures that come up again and again, then a lab to see the rules work together.
Gaps in header coverage
- Only the app shell has the header. The single-page app's
index.htmlsendsframe-ancestors, but the server-rendered login page, the OAuth consent page, the password reset page, or the 404 page is served by a different service without it. Attackers go straight to those. - The CDN or proxy strips or replaces headers. A cache rule that sets its own CSP can drop yours. Check headers on the real production URL, not on your dev server.
- One embed route opened everything. A team needs partners to frame
/embed/*, removes the header globally, and every page becomes frameable. Scope the exception to the route. - The wrong keyword.
frame-ancestors selfwithout quotes means a host namedself, so even your own pages can no longer frame you, and the usual "fix" is to delete the header.'self'with quotes is the keyword. Chrome logs a console warning about it, but nothing fails loudly. - ALLOW-FROM left in place.
X-Frame-Options: ALLOW-FROM https://partner.exampleis ignored by modern browsers, so the page is frameable by anyone unlessframe-ancestorsis also set.
How you notice
A blocked frame logs a console error such as "Refused to frame ... because an ancestor violates the following Content Security Policy directive". Turn that into data: add a report-to endpoint to your CSP so frame-ancestors violations are reported, and you will see both partners whose embeds you broke and attackers who tried to frame you. In CI, a simple test that fetches your important routes and checks for the header catches most regressions before they ship.
// Runs in CI against a preview deploy.
const routes = ["/", "/login", "/settings", "/oauth/authorize", "/does-not-exist"];
for (const route of routes) {
const res = await fetch(new URL(route, process.env.PREVIEW_URL), { redirect: "follow" });
const csp = res.headers.get("content-security-policy") ?? "";
const xfo = res.headers.get("x-frame-options") ?? "";
if (!/frame-ancestors\s+('none'|'self')/.test(csp) || !/^(DENY|SAMEORIGIN)$/i.test(xfo)) {
throw new Error(`${route} can be framed: csp="${csp}" xfo="${xfo}"`);
}
}
When the header breaks your own product
The opposite failure is just as common: the header is right in principle but breaks something real. Your app frames its own help center on another subdomain, which 'self' does not cover; a partner embed stops working after a domain change; your marketing site shows an app preview in a frame. Each needs an explicit entry in frame-ancestors, added on purpose, never a removal of the header.
Lab: can the attacker frame, overlay, or script your page?
In the lab below, an attacker page on evil.example tries to frame bank.example/settings. Toggle the bank's headers, the session cookie's SameSite value, and the sandbox tokens the attacker puts on the frame. For each combination, the lab works out what the browser would do: whether the page renders, whether the user is logged in inside it, whether the bank's own scripts (including a frame buster) run, and whether a hidden click does damage.
HTTP/1.1 200 OK
Set-Cookie: sid=31c9; Secure; HttpOnly; SameSite=None
<!-- on evil.example: invisible and on top -->
<iframe class="target"
src="https://bank.example/settings"></iframe>
Things to try: turn every header off and set the cookie to None to see the classic attack. Then turn on only the frame buster and add the sandbox without allow-top-navigation: the buster fails but the buttons still work. Then set Lax and see the frame load logged out. Finally, turn on frame-ancestors 'self' and notice that nothing else matters any more.
Think first
In the lab, a SameSite=Lax cookie already makes the framed bank page load logged out. Why would you still send frame-ancestors?
Some actions do not need a login: sign-up forms, "subscribe", "report this post", or a logged-out page that starts an OAuth flow with a one-click approve.
Not every cookie stays Lax. Single sign-on, embedded features, and older code often set SameSite=None, and one such cookie is enough for the attack.
Lax is a default only in Chromium. A header is an explicit, browser-checked rule that does not depend on how every cookie on the domain was set.
In real products
The word clickjacking was coined in 2008 by Robert Hansen and Jeremiah Grossman, who showed that a framed Adobe Flash settings page could be used to switch on a visitor's webcam. In February 2009 a "Don't click" worm spread on Twitter: a framed tweet button sat under a bait button, so every victim tweeted the link onward. Likejacking on Facebook followed in 2010. Incidents like these are why every major browser adopted X-Frame-Options within a few years of Internet Explorer 8 introducing it.
Products that refuse to be framed
Open DevTools on almost any large app's main pages (GitHub, Google accounts, banks, admin consoles) and you will find X-Frame-Options and usually frame-ancestors 'none' or 'self'. OAuth providers refuse framing on their consent screens for a second reason too: a login form inside someone else's page teaches users to type passwords where they cannot see the address bar. That is why "Sign in with Google" asks for your password in a popup or a full redirect, never in an iframe inside another site.
Products built to be framed
- Payment fields. Stripe Elements and similar products put each card field in an iframe served by the payment provider. The card number never enters the shop's page, so the shop's own scripts (and any XSS on the shop) cannot read it. That is also why such shops have a much smaller PCI compliance scope. The frames talk to the shop's page with
postMessagefor focus, validation, and resizing. - Video players and maps. YouTube and Google Maps offer a separate embed URL that is built to be framed by anyone, while the main site is not. The embed has no destructive buttons, so being framed by an attacker gains nothing.
- Plugins and extensions inside apps. Figma runs a plugin's UI in an iframe that, by default, has an opaque (
null) origin and talks to the plugin code only by messages, so the plugin UI cannot read Figma's page or the user's session. - User code and generated content. Code playgrounds such as CodePen and CodeSandbox, and AI products that render generated apps, run the untrusted code in frames served from separate domains, often sandboxed as well. GitHub serves raw user files from
githubusercontent.comfor the same reason. - Email clients. Webmail clients show HTML emails in sandboxed frames or after heavy sanitizing, with scripts off and remote images proxied, because an email is untrusted HTML from a stranger.
Frameworks and platforms
Helmet, the common Express security middleware, sets X-Frame-Options: SAMEORIGIN and a CSP with frame-ancestors 'self' by default. Django's XFrameOptionsMiddleware is on in new projects and has sent DENY by default since Django 3.0, and Rails sends X-Frame-Options: SAMEORIGIN by default. Next.js and most static hosts send nothing unless you configure headers, so a framework choice can silently decide whether you are protected. Always check the real response.
Using it in a system design interview
Clickjacking rarely is the interview question, but iframes show up in many designs: an embeddable widget, a payment checkout, a rich email client, a code playground, a dashboard that embeds charts, or an app that previews AI-generated pages. Mentioning the right control in one or two sentences, at the right moment, is a strong signal.
When to bring it up
- You draw a page with sensitive actions (settings, payments, admin): say it sends
frame-ancestors 'none'plusX-Frame-Options: DENY, set at the edge for every HTML response. - You design something others embed: allow framing only on the embed route, keep risky actions in a popup, use partitioned cookies or the Storage Access API, and define a
postMessageprotocol with origin checks. - You render untrusted content: put it in a sandboxed frame, on a separate site if it runs scripts and needs an origin, and never
allow-scripts allow-same-originon your own origin. - You embed third parties: sandbox what you can, grant features with
allowonly as needed, and cap the whole page with aPermissions-Policyheader.
What to draw
Draw two boxes, the parent page and the frame, with their origins written on them. On the frame's edge write its response header (frame-ancestors), and on the parent's side of the edge write the attributes (sandbox, allow). Between them draw one arrow labelled postMessage with "exact origin check" on both ends. That one picture answers most follow-up questions.
| Level | What a strong answer covers |
|---|---|
| Junior | What clickjacking is, and that a header (X-Frame-Options or frame-ancestors) stops it; iframes need a title |
| Mid | Both headers and which one wins; sandbox tokens and the scripts plus same-origin trap; checking event.origin and naming a target origin |
| Senior | Designing an embeddable product: route-scoped framing, popups for sensitive steps, partitioned cookies and Storage Access, a versioned message protocol, separate-site hosting for untrusted code, and double-clickjacking for one-click approvals |
Check your understanding · Exercise 7
You are designing an embeddable booking calendar that salons put on their websites. Which choices are sound? Select all that apply.
Summary
- Clickjacking puts your real page in an invisible or disguised frame so the user clicks your real button; CSRF tokens and script CSP do not stop it.
- Send
Content-Security-Policy: frame-ancestors 'none'or'self'plus a matchingX-Frame-Optionson every HTML response; an enforced frame-ancestors wins over X-Frame-Options, and ALLOW-FROM is obsolete. - Frame-busting scripts are defeated by sandboxing and other tricks; headers are checked before anything renders.
- Untrusted content goes in a sandboxed iframe; never combine
allow-scriptswithallow-same-originfor content on your own origin, and never use an unsandboxedsrcdocfor untrusted HTML. - With postMessage, check
event.originexactly (andevent.source), name a specific target origin, and validate the data shape. - Embeds live with
SameSite, third-party cookie blocking, and storage partitioning: design for no third-party cookies, usingPartitionedcookies, popups, or the Storage Access API. - Cross-origin frames get powerful features only through the
allowattribute, and yourPermissions-Policyheader caps them for the whole page. - Double-clickjacking swaps a top-level page under the cursor and needs no frame, so one-click approvals should also wait for a real gesture on the page or ask for a deliberate confirmation.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is clickjacking, and how do you stop it?
Clickjacking is when an attacker's page loads your real page in an iframe, hides or disguises it with CSS (opacity 0, overlays, a fake cursor), and lines up your button under something the user wants to click. The user makes a real click on your real page with their real session.
CSRF tokens do not help, because the request comes from your own page. The fix is to tell the browser who may frame you: Content-Security-Policy: frame-ancestors 'none' or 'self', plus X-Frame-Options: DENY or SAMEORIGIN for old browsers. The browser checks the header before rendering, so there is nothing to click.
I would set it at the edge for every HTML response, including error and login pages, and allow framing only on routes built to be embedded. For one-click approvals I would also think about double-clickjacking, which needs no frame, and enable the button only after a real gesture on the page or ask for a deliberate confirmation.
Question 2
X-Frame-Options and frame-ancestors are both set and disagree. What happens?
If the CSP with frame-ancestors is enforced, modern browsers ignore X-Frame-Options entirely; the HTML standard says so. If the CSP is report-only, X-Frame-Options still applies.
Browsers without frame-ancestors support, such as Internet Explorer, use X-Frame-Options. So the two should mean the same thing, with frame-ancestors as the source of truth.
X-Frame-Options cannot express an allowlist: ALLOW-FROM is obsolete and makes modern browsers ignore the header. For partners, use frame-ancestors with their origins.
Question 3
You need to show user-written HTML that can include scripts. How do you embed it safely?
In an iframe with sandbox, starting from no tokens and adding allow-scripts and only what else it needs. Without allow-same-origin the content gets an opaque origin, so its scripts cannot touch my page, cookies, or storage.
If it needs a real origin (for storage, say), I serve it from a separate registrable domain, like githubusercontent.com, and only then add allow-same-origin. I never combine allow-scripts and allow-same-origin for content on my own origin, because the script can remove its own sandbox.
For quick previews, a sandboxed srcdoc works, but never an unsandboxed one, since srcdoc inherits my origin. I talk to it over postMessage, checking event.source because its origin is null.
Question 4
How do you use postMessage safely between a page and an embedded widget?
The receiver compares event.origin with an exact expected origin using ===, and for frames it also checks event.source is the expected window. String tricks like includes or endsWith can be fooled.
The sender always passes a specific targetOrigin, never "*" for anything private, so the data is not delivered if the frame has been navigated elsewhere.
The receiver validates the message shape and type before acting, never feeds message data to innerHTML, eval, or location, and treats claims such as "paid" as hints to confirm with the server. For long sessions I hand over a MessageChannel port once and talk over it.
Question 5
Design the frontend security of an embeddable comments widget used on thousands of blogs.
Framing: the /embed route sends frame-ancestors https: (or the registered blog origins), and every other route sends 'none'. Inside the frame there is nothing destructive in one click; sign-in and account changes open a popup on our origin.
Identity: third-party cookies are blocked or partitioned in Safari and Firefox, so the widget uses a Partitioned session cookie started by the popup login, or document.requestStorageAccess() from a click to reuse the main login.
Messaging: a small typed postMessage protocol (resize, ready, theme) with exact origin checks and target origins on both sides. The host page gives the frame a title, loading="lazy", and no allow features it does not need.
Practice it
These design problems lean on clickjacking and iframes.
- Design an embeddable poll widgetMedium
- Design an e-signature workflow like DocuSignMedium
- Design a scheduling link like CalendlyMedium
- Design a secure payment formHard
- Design an embeddable third-party widgetHard
- Design a collaborative design tool like FigmaHard
- Design an online code playground like CodePenHard
- Design a no-code website builderHard
References
- MDN: Clickjacking The attack and its defences, with examples.
- MDN: CSP frame-ancestors Syntax, values, and browser support.
- MDN: X-Frame-Options DENY, SAMEORIGIN, and why ALLOW-FROM is obsolete.
- MDN: The iframe element (sandbox and allow) Every sandbox token and attribute.
- MDN: Window.postMessage() The API and its security concerns.
- OWASP Clickjacking Defense Cheat Sheet Header guidance and legacy frame-busting notes.
- web.dev: Play safely in sandboxed IFrames A practical guide to sandbox tokens.
- MDN: Storage partitioning How browsers key storage and cookies in cross-site frames.
- Paulos Yibelo: DoubleClickjacking The December 2024 write-up, with the gesture-check mitigation.
- Rydstedt et al.: Busting Frame Busting (2010) The Stanford study of how frame-busting scripts fail.
Read next
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Cross-site request forgery (CSRF)An attack where another site makes the user's own browser send a request to your app, with the user's cookies attached, so the app thinks the user asked for it. SameSite cookies, tokens, and origin checks stop it.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Inside the browserHow a modern browser splits its work across processes and threads, why your JavaScript shares one main thread with layout, paint, and input, and how the event loop, workers, and OffscreenCanvas decide whether a page stays responsive.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.