Under the Hood
Frontend

Cross-origin iframes: postMessage and focus routing

A cross-origin <iframe> isn't a piece of your page — it's a second, fully separate document, and the browser deliberately gives you no automatic access to its events, its DOM, or its focused element. This lesson covers the one sanctioned channel across that boundary, postMessage, the origin check that makes it safe rather than a liability, and how to detect focus moving into an iframe without ever seeing what's focused inside it.

Cross-origin iframes: postMessage and focus routing

Drop a payments widget into your checkout page as an <iframe src="https://pay.example.com/...">, and the first thing you'll want to do is talk to it — tell it the order total, find out when the card was validated, know when the user clicked "submit" inside it. You reach for the obvious tools first: attach a click listener to document, check document.activeElement, maybe try to read the iframe's contentDocument. All three fail, or fail silently, or throw. Not because iframes are broken, but because they're working exactly as designed.

The event journey lesson showed events traveling freely up and down one document's tree — window to target and back, every ancestor getting a turn. That entire model assumes a single tree. The moment <iframe> points at a different origin, you don't have one tree anymore. You have two completely separate documents, each with its own window, its own DOM, its own event system, sitting next to each other on screen — and the browser will not let events, DOM access, or focus state cross between them except through one narrow, opt-in channel. That isolation is the entire subject of this lesson.

Here's the map: why the isolation exists and what it actually blocks, the postMessage() channel that both sides have to deliberately choose to use, the event.origin check that's the only thing standing between "sanctioned channel" and "open door," and finally what focus looks like from the parent's side once it moves into the iframe, and how to detect that transition using only signals the browser is willing to give you.

The isolation is the feature, not a limitation

Say the isolation weren't there — say a parent page could attach a listener and see every keystroke and click inside an embedded cross-origin iframe. Now think about what gets embedded in iframes across the web: login forms, card-entry widgets, bank-consent screens, "Sign in with X" buttons. If the embedding page could read what happens inside any of those, every site that embeds a login form could silently harvest the password typed into it. The same-origin policy exists precisely to prevent that: from the browser's perspective, a cross-origin iframe's document is a foreign document, no more accessible to the parent than a page open in a completely different tab, and its events, its DOM, and its focus state stay inside it by default. This isn't an inconvenience you route around — it's a security boundary you're supposed to respect, and building anything that tries to defeat it (short of a real vulnerability in the browser) simply won't work.

Notice what's dotted (blocked) versus what's a real, drawn arrow (sanctioned): a click inside the iframe bubbles happily within that iframe's own document, exactly like the capture/bubble journey you already know, and then it simply stops there. It never reaches the parent's window. The only line crossing the boundary in either direction is the one channel the browser designed on purpose for this: postMessage.

postMessage: the one door, and both sides have to open it

postMessage() doesn't give either side access to the other's DOM. It gives them a mailbox. Any script that holds a reference to another window — the iframe's contentWindow, window.parent, a popup opened with window.open() — can send that window a message, even across origins, and the receiving side chooses whether to listen at all:

// Parent page, sending into the iframe
const iframe = document.querySelector('#payments-widget');

iframe.contentWindow.postMessage(
  { type: 'SET_ORDER_TOTAL', amount: 4999, currency: 'USD' },
  'https://pay.example.com' // targetOrigin — the message is only delivered
                             // if the iframe's current origin matches this
);
// Inside the iframe, on pay.example.com
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://your-app.com') return; // covered next section

  if (event.data?.type === 'SET_ORDER_TOTAL') {
    renderTotal(event.data.amount, event.data.currency);
  }
});

And the reverse direction, the widget telling the parent it's done:

// Inside the iframe, once the card is validated
window.parent.postMessage(
  { type: 'CARD_VALIDATED', last4: '4242' },
  'https://your-app.com'
);

That's the whole surface. No shared DOM, no shared event bus, no way for either side to reach in and inspect the other's internals — just structured, serializable data, handed across the boundary, that each side decides for itself whether to act on. It's deliberately the narrowest thing that could still be called "communication": not "let the parent see the iframe," but "let the two sides agree, at both ends, to exchange messages if they choose to."

Why event.origin isn't optional

Here's the trap: window.addEventListener('message', handler) fires for a message from any window that has a reference to yours and decides to call postMessage at it — not just the one you intended to talk to. If an attacker lures a user to a malicious page that also holds a reference to your window (say, one they opened as a popup from a link, or one embedded alongside your iframe on some third-party page), that page can call postMessage at you too. Your handler has no built-in way to know who actually sent a given message. event.data tells you what arrived, not who it came from — that's event.origin, and only event.origin.

Here's the broken version, which is easy to write because it "just works" in every normal case during testing:

// BROKEN — trusts the message without checking who sent it
window.addEventListener('message', (event) => {
  if (event.data.type === 'CARD_VALIDATED') {
    markOrderAsPaid(event.data.last4); // any window on the internet
                                        // can trigger this
  }
});

Nothing here inspects event.origin. Any script anywhere that obtains a reference to this window — and a reference is easy to obtain, since window.open() and iframe.contentWindow hand them out freely — can post a fabricated CARD_VALIDATED message and your handler will act on it as if it came from the real payments widget.

The fix is one line, checked against an explicit allowlist, not a pattern match or a substring check:

// FIXED — validate the sender before trusting the payload
const TRUSTED_ORIGIN = 'https://pay.example.com';

window.addEventListener('message', (event) => {
  if (event.origin !== TRUSTED_ORIGIN) return; // reject everyone else, silently

  if (event.data?.type === 'CARD_VALIDATED') {
    markOrderAsPaid(event.data.last4);
  }
});

What the parent can see once focus moves into the iframe: the iframe, and nothing more

Focus has exactly the same isolation as events, for exactly the same reason. Click into an <input> inside a same-origin, same-document form, and document.activeElement returns that input. Click into a field inside a cross-origin iframe, and document.activeElement in the parent document returns the <iframe> element itself — not the input, not any hint of what's focused inside it. From the parent's point of view, focus moved into "that iframe, as an opaque whole." The parent has no more visibility into which element inside grabbed focus than it has into what was typed there — same boundary, same policy, same reasoning: if the parent could see focus targets inside a cross-origin document, it could infer the shape and behavior of a form it isn't supposed to be able to inspect.

This is genuinely useful information on its own, though — knowing that focus left your document and landed on this specific iframe is enough to do things like pause a background animation, show a "you're now in the payments widget" indicator, or track analytics on iframe engagement, all without needing or wanting to know what's focused inside it.

Detecting the transition with only sanctioned signals

The trick is combining two things the parent document genuinely does receive: a blur event on window (which fires whenever focus leaves the parent document for any reason, including moving into a same-page iframe), and a follow-up check of document.activeElement. Neither one alone tells you the full story — blur alone doesn't say where focus went, and checking activeElement on its own leaves you polling — but together they say precisely "focus just left this document, and it landed on this iframe":

const iframe = document.querySelector('#payments-widget');

window.addEventListener('blur', () => {
  // Give the browser a tick to actually update activeElement —
  // on some browsers this check needs to happen just after the
  // blur event, not synchronously inside it.
  setTimeout(() => {
    if (document.activeElement === iframe) {
      console.log('Focus moved into the payments widget');
      onFocusEnteredIframe();
    }
  }, 0);
});

// And the reverse — focus coming back out is an ordinary `focus`
// event on window, no iframe reference needed:
window.addEventListener('focus', () => {
  console.log('Focus returned to the parent document');
});

Nothing here reads the iframe's DOM, its focused input, or anything typed inside it. It only asks two questions the parent is always allowed to ask: "did focus just leave me?" and "is the thing that now has my document's focus this particular element?" That's enough to build real UI behavior around the boundary, without ever needing to cross it.

Go deeper

  • MDN — Window.postMessage() The authoritative reference, including the explicit warning about always specifying a targetOrigin and always validating event.origin on receipt.
  • MDN — Same-origin policy The security model this whole lesson is a consequence of — why isolation between origins is the default, not an obstacle.
  • MDN — Document.activeElement Confirms exactly what activeElement returns for a page containing a focused cross-origin iframe, and why it can't see further.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. A teammate calls cross-origin iframe isolation 'a browser limitation we have to work around.' What's the more accurate framing, and what real attack does the isolation prevent?
  2. Why does a click inside a cross-origin iframe never trigger a bubble-phase listener attached to the parent document's window, even though bubbling works fine within the iframe's own document?
  3. You call iframe.contentWindow.postMessage(data, '*') instead of specifying the real target origin. What could go wrong, concretely, and under what circumstance would it actually happen?
  4. A message handler checks event.data.type === 'AUTH_TOKEN' before acting, but never checks event.origin. Describe a realistic way an attacker could exploit that, without needing any bug in the browser itself.
  5. After a user clicks into an input inside a cross-origin iframe, what does document.activeElement return in the parent document, and why can't it return the actual input element?
  6. Why is a blur listener on window, by itself, not enough to detect that focus specifically moved into a given iframe — what's the second check needed, and why?
  7. Explain the difference between the targetOrigin argument on the sending side of postMessage and the event.origin check on the receiving side — why do you need both, rather than just one?