cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

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.

An exploded view of a clickjacking page. The bottom layer, which the user sees, is evil.example with the text Win a free phone and a Claim prize button. Above it floats an iframe of bank.example at opacity 0, lined up so its Delete account button sits exactly over Claim prize. Dashed lines show the two buttons line up, and the user's click lands on the top layer.evil.exampleWin a free phone!Claim prizebank.example in an iframeopacity 0, on topDelete accountthe click landson the top layer
The user sees only the bottom layer. The top layer is the real bank page in an invisible frame, moved so its button sits exactly over the bait, so the click goes to the bank.

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=Lax or Strict is 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 with SameSite=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.

The same element, two sets of controls. The framed page controls who may frame it; the parent controls what the frame may do.
QuestionYou are framed (the child)You embed (the parent)
Main riskClickjacking: your buttons get clicked under a disguiseUntrusted content attacks your page or your users
Main toolCSP frame-ancestors, plus X-Frame-OptionsThe sandbox and allow attributes on the iframe
Where it is setA response header on your pageMarkup on the embedding page
Talking acrosspostMessage, checking who sent itpostMessage, 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.

A parent page on shop.example contains its own page content and an iframe from pay.example showing a card form. A note on the parent side says the parent controls size, place, and opacity, plus the sandbox and allow attributes. A note inside the frame says the frame controls its own content and the frame-ancestors header. The only arrow between page content and card form is labelled postMessage. Neither side can open the other side's lock.parent page: shop.examplepage contentthe parent controls:size, place, opacitysandbox and allowiframe: pay.examplecard formthe frame controls:its own contentframe-ancestorspostMessageneither side can open the other side's lock
The parent owns the box and its attributes (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.postMessage lets 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?

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.

html
<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 whole trick is CSS: the frame is moved so its button sits over the bait, then made invisible while staying on top.

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

A wireframe of the attacker page evil.example with a Claim prize button, built up in five steps: the bait page alone, then an iframe of bank.example beside it, then the frame moved so its Delete account button covers Claim prize, then the frame made invisible, then the user's click landing on Delete account.evil.exampleWin a free phone!Claim prizethe page theuser sees
The attacker's page on 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: none and 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.

  1. 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.
  2. Behind it, the new window uses its window.opener link 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.
  3. 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.
  4. The second click of the double click lands on your real Allow or Confirm button, at full opacity, in the top window.
A timeline with two lanes. The popup lane shows a window saying double click to continue, then closed at the first click. The original tab lane shows evil.example, then the bank's consent page loaded behind the popup, then Allow clicked at the second click. Labels under the axis say the tab is sent to the bank behind the popup, and that the double click takes a split second.popuporiginal tabdouble click to continueclosedevilbank consent pageAllow clickedsent to the bank behind the popupclick 1 here, click 2 a split second later
No frame is involved: the original tab is sent to the real page through 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, use frame-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.

Three annotated header lines. First, Content-Security-Policy with frame-ancestors, the keyword 'self' meaning same origin, and https://shop.example, one partner origin. Second, X-Frame-Options SAMEORIGIN, ignored when frame-ancestors is enforced and used by old browsers. Third, X-Frame-Options ALLOW-FROM with a partner URL, marked obsolete: ignored, so no protection.Content-Security-Policy:frame-ancestors'self'https://shop.examplethe directivesame originplus one partnerX-Frame-Options:SAMEORIGINignored when frame-ancestors isenforced; old browsers use itX-Frame-Options:ALLOW-FROM https://shop.exampleobsolete: no protection
One policy, two headers. frame-ancestors carries the real list; X-Frame-Options is the fallback, and ALLOW-FROM silently turns the protection off.
frame-ancestors values. Quotes matter: 'self' and 'none' are keywords in single quotes; hosts are not quoted.
ValueMeaningTypical use
'none'Never framed, by anyoneLogin, settings, admin, checkout
'self'Only by pages on the same originApps that frame their own pages, such as a preview pane
'self' https://shop.exampleSame origin plus listed originsA widget made for a few known partners
https://*.partner.exampleAny subdomain of one siteA partner with many storefront hosts
https:Any HTTPS pagePublic 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.

A flowchart. A response arrives for a frame. Does it have an enforced CSP with frame-ancestors? If yes, do all ancestors match? Yes renders, no blocks. If no enforced frame-ancestors, what is the X-Frame-Options value? Missing, invalid, or ALLOW-FROM renders with no protection. DENY or SAMEORIGIN blocks a cross-origin attacker.frame responseenforced frame-ancestors?yesnoall ancestors match?X-Frame-Options?yesnorenderblockrenderno valid valueblock cross-originDENY, SAMEORIGIN
An enforced 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?

X-Frame-Options only

Send X-Frame-Options: DENY or SAMEORIGIN on every HTML response.

Pros

  • One simple value, understood by every browser ever shipped
  • Easy to add at a proxy or CDN

Cons

  • Cannot list partner origins
  • No reporting
  • Easy to break by adding the obsolete ALLOW-FROM

frame-ancestors only

Add frame-ancestors 'none' or 'self' to your CSP header.

Pros

  • Allowlists, wildcards, and scheme sources
  • Checks every ancestor
  • Violations can be reported with the rest of your CSP

Cons

  • Ignored by Internet Explorer
  • Lives inside a long CSP string that teams edit for other reasons and can break

Both, with the same meaning

frame-ancestors 'self' plus X-Frame-Options: SAMEORIGIN, or 'none' plus DENY.

Pros

  • Modern browsers use the precise one, old ones the simple one
  • Security scanners and audits expect both

Cons

  • Two places to keep in sync; for a partner allowlist the old header can only say DENY or SAMEORIGIN

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.

js
// 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" without allow-top-navigation keeps your page working as normal but blocks the top navigation. Your buster fails silently while your buttons still work. (allow-same-origin is harmless to the attacker here, because your page is cross-origin to theirs.)
  • The attacker blocks the navigation. Old tricks used a beforeunload prompt 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, no target="_blank" links, no alert or confirm.
  • 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.
A matrix of sandbox setups against five abilities: run scripts, origin, submit forms, open popups, navigate the top window. No sandbox: yes, real origin, yes, yes, yes. Empty sandbox: no, null origin, no, no, no. allow-scripts: yes, null, no, no, no. allow-scripts plus allow-forms: yes, null, yes, no, no. allow-scripts plus allow-same-origin on your own origin: everything yes with your origin, because the frame can remove its own sandbox.scriptsoriginformspopupstop navno sandboxsandbox (empty)allow-scriptsscripts + formsscripts + same-originyesrealyesyesyesnonullnononoyesnullnononoyesnullyesnonoyesyoursyesyesyes
Each token gives back one column. The last row is the trap: 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

The tokens you will use most. Each one gives back one power; leave out everything the content does not need.
TokenGives backWatch out for
allow-scriptsRunning JavaScriptSafe alone: the scripts run in an opaque origin
allow-same-originThe content's real origin, with its cookies and storageNever combine with allow-scripts for content on your origin
allow-formsSubmitting formsPhishing forms can now post anywhere
allow-popupsOpening new windowsPopups inherit the sandbox unless you also add allow-popups-to-escape-sandbox
allow-modalsalert, confirm, printDialog spam over your page
allow-top-navigation-by-user-activationNavigating your tab, but only right after a clickPrefer this to plain allow-top-navigation
allow-downloadsStarting downloadsDrive-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.

js
// 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
Scripts plus same-origin on your own origin is no sandbox at all.

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.

Tokens
Content comes from
html
<iframe sandbox="allow-scripts"
        src="https://app.example/preview/42"></iframe>
A sandboxed iframe with tokens allow-scripts, content from a URL on your origin. run its own JavaScript: yes. its origin: opaque (null). read your cookies and storage: no. remove its own sandbox: no. submit forms: no. open popups: no. navigate your tab: no.run its own JavaScriptyesits originopaque (null)read your cookies and storagenoremove its own sandboxnosubmit formsnoopen popupsnonavigate your tabno
Each token gives back one power. Without allow-same-origin the content gets an opaque origin, so even running scripts cannot reach you.

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).

ts
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);
A sandboxed srcdoc preview: scripts run, but in an opaque origin with nothing to steal.

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?

Sanitize and insert into your page

Run the HTML through a sanitizer such as DOMPurify, then insert it into a div.

Pros

  • Looks native, inherits your styles
  • No frame sizing problems

Cons

  • Scripts must be stripped, so interactive content cannot work
  • One sanitizer bug is XSS on your origin
  • Their CSS can still restyle your page

Sandboxed srcdoc frame

sandbox="allow-scripts" with the HTML in srcdoc; talk to it with postMessage.

Pros

  • Scripts can run safely in an opaque origin
  • No extra domain to run

Cons

  • The content cannot use cookies or storage
  • Messages arrive with origin null, so you must check event.source

Separate-site frame

Serve the content from a different registrable domain, sandboxed with the tokens it needs.

Pros

  • Content gets a real origin, so storage and allow-same-origin work
  • Best isolation, its own process under Site Isolation

Cons

  • A second domain, TLS certificate, and deploy path
  • Third-party storage rules apply inside it

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).

A sequence diagram between the shop API, the shop page, the pay frame, and an evil page. The shop page posts init to the pay frame with target origin pay.example. The pay frame posts paid, and the origin check passes. The evil page posts paid too, and the shop page drops it because the origin is wrong. The shop page then asks its own API to verify order 42, and the API replies confirmed.shop APIshop pagepay frameevil pagepostMessage: initpaid, origin okpaid, wrong origindroppedverify order 42confirmed
The shop names the target origin when it sends, checks 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

  1. The receiver checks event.origin against 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 use includes, startsWith, or a loose regex: https://pay.example.evil.com contains pay.example.
  2. 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.
  3. 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.
ts
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);
Origin, source, and shape: three checks before any message data is used.

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.

ts
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 sets location.href = url with no origin check. An attacker frames the widget, sends javascript: or a phishing URL, and gets XSS or an open redirect.
  • An auth popup posts the access token back to window.opener with target origin "*". An attacker opens the auth URL from their own page and receives the token.
  • A resize handler trusts event.data.height and 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 for example.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.

A table of storage keys. widget.example framed on blog-a.example gets jar 1 holding theme=dark. The same widget framed on blog-b.example gets jar 2, empty. widget.example opened as a top-level page gets the first-party jar with the session. A note says requestStorageAccess lets a frame reach the first-party jar after a click.top-level siteframe originstorage it getsblog-a.examplewidget.examplejar 1: theme=darkblog-b.examplewidget.examplejar 2: emptywidget.example(top level)first-party: sidrequestStorageAccess() lets the framereach the first-party jar after a click
The key is the pair (top-level site, frame origin), so one widget gets a separate jar on every site that embeds it. 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
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=/
A widget route that may be embedded only by partners, with a session cookie that works inside their frames.

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.

html
<!-- 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 in src. 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-Policy header wins over the attribute. Permissions-Policy: camera=() on your page means no frame can get the camera, whatever its allow says.
  • 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 iframe needs a title that 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.html sends frame-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 self without quotes means a host named self, 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.example is ignored by modern browsers, so the page is frameable by anyone unless frame-ancestors is 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.

ts
// 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}"`);
  }
}
A header regression test. Include error pages and every service that renders HTML.

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.

X-Frame-Options
frame-ancestors
Session cookie SameSite
Bank page
Attacker
http
HTTP/1.1 200 OK
Set-Cookie: sid=31c9; Secure; HttpOnly; SameSite=None
html
<!-- on evil.example: invisible and on top -->
<iframe class="target"
        src="https://bank.example/settings"></iframe>
evil.example frames bank.example/settings. the frame renders: yes. logged in inside it: yes: cookie sent. the attacker can overlay it: yes: CSS is theirs. bank scripts run: yes. the frame buster: none. the hidden click: account deleted.the frame rendersyeslogged in inside ityes: cookie sentthe attacker can overlay ityes: CSS is theirsbank scripts runyesthe frame busternonethe hidden clickaccount deleted
Classic clickjacking: the page renders, the session cookie is sent, and the attacker's CSS hides it over the bait.

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?

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 postMessage for 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.com for 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.
A wireframe of shop.example/checkout. The shop's own fields, name and address, sit at the top. Below them a dashed iframe from pay.example holds the card number and expiry fields. The Pay button belongs to the shop. Notes on the right say the iframe comes from pay.example, the shop's scripts cannot read the card, and the two talk by postMessage.shop.example/checkoutnameaddressiframe: pay.examplecard numberMM/YYPayserved bypay.exampleshop JS cannotread the cardpostMessagesize, focus, errors
The card fields look like part of the shop, but they live in the provider's frame. A bug or a bad script on the shop cannot read the card number, because the same-origin policy keeps it out.

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' plus X-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 postMessage protocol 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-origin on your own origin.
  • You embed third parties: sandbox what you can, grant features with allow only as needed, and cap the whole page with a Permissions-Policy header.

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.

How deep to go, by level. Go deeper only if the interviewer follows up.
LevelWhat a strong answer covers
JuniorWhat clickjacking is, and that a header (X-Frame-Options or frame-ancestors) stops it; iframes need a title
MidBoth headers and which one wins; sandbox tokens and the scripts plus same-origin trap; checking event.origin and naming a target origin
SeniorDesigning 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 matching X-Frame-Options on 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-scripts with allow-same-origin for content on your own origin, and never use an unsandboxed srcdoc for untrusted HTML.
  • With postMessage, check event.origin exactly (and event.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, using Partitioned cookies, popups, or the Storage Access API.
  • Cross-origin frames get powerful features only through the allow attribute, and your Permissions-Policy header 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?

Question 2

X-Frame-Options and frame-ancestors are both set and disagree. What happens?

Question 3

You need to show user-written HTML that can include scripts. How do you embed it safely?

Question 4

How do you use postMessage safely between a page and an embedded widget?

Question 5

Design the frontend security of an embeddable comments widget used on thousands of blogs.

Practice it

These design problems lean on clickjacking and iframes.

References

Read next