Cross-window communication

Open two tabs. One is your bank, the other is some random site you clicked into from a forum. Should the random site be able to reach into the bank tab and read your account number? Obviously not. The browser enforces that wall for you, and the rule that defines the wall is called the Same Origin policy.

The scenario the policy guards against is concrete: you have a page from maya-vance.com open, and another from gmail.com. A script on maya-vance.com must not be able to read your inbox on gmail.com. The policy exists to stop exactly that kind of information theft.

Same Origin

Two URLs have the same origin when three things match: the protocol, the domain, and the port. All three. Miss one and it’s a different origin.

These URLs all share one origin — the extra path doesn’t matter:

  • http://site.com
  • http://site.com/
  • http://site.com/my/page.html

These do not match http://site.com:

  • http://www.site.com — different domain (www. is its own subdomain)
  • http://site.org — different domain (.org vs .com)
  • https://site.com — different protocol
  • http://site.com:8080 — different port
protocol
https
://
host / domain
site.com
:
port
443
/path?query
All three boxes must match for two URLs to be same-origin. The trailing path is ignored.
An origin is the triple (protocol, host, port). Change any one and it's a new origin.

What the policy actually grants and forbids:

  • If you hold a reference to another window — a popup you made with window.open, or a window living inside an <iframe> — and that window is same origin, you get full access. Its variables, its document, everything.
  • If it’s a different origin, that access is cut off. You can’t read its variables or touch its document. The single exception is location: you’re allowed to write to it (redirecting the user), but you can’t read it. Writing but not reading means you can send someone somewhere without learning where they currently are — no leak.

In action: iframe

An <iframe> embeds a whole separate window inside your page. It carries its own document and its own window object, independent of the parent.

Two properties reach into it:

  • iframe.contentWindow — the window object inside the frame.
  • iframe.contentDocument — the document inside, a shorthand for iframe.contentWindow.document.

Every time you reach through one of these, the browser checks the origin of the framed page. Mismatch, and the access throws — except for writing location, which stays legal.

Here’s a cross-origin frame refusing almost everything:

<iframe src="https://example.com" id="iframe"></iframe>

<script>
  iframe.onload = function() {
    // we can grab the reference to the inner window
    let iframeWindow = iframe.contentWindow; // OK
    try {
      // ...but not the document inside it
      let doc = iframe.contentDocument; // ERROR
    } catch(e) {
      alert(e); // Security Error (another origin)
    }

    // we also can't READ the URL of the page in the iframe
    try {
      // Can't read href from the Location object
      let href = iframe.contentWindow.location.href; // ERROR
    } catch(e) {
      alert(e); // Security Error
    }

    // ...but we CAN write location (loading something else into the iframe)!
    iframe.contentWindow.location = '/'; // OK

    iframe.onload = null; // drop the handler so it doesn't fire again after the redirect
  };
</script>

Only two operations survive: grabbing iframe.contentWindow, and writing location. Everything else throws a security error.

OK
iframe.contentWindow — get the reference
OK
contentWindow.location = ‘…’ — write (redirect)
iframe.contentDocument — read the document
contentWindow.location.href — read the URL
any variable / DOM node inside — read or write
Cross-origin iframe: the access matrix.

When the <iframe> is same origin, the wall disappears and you can do whatever you like:

<!-- iframe from the same site -->
<iframe src="/" id="iframe"></iframe>

<script>
  iframe.onload = function() {
    // full access
    iframe.contentDocument.body.prepend("Hello, world!");
  };
</script>

Because the frame below is same origin with the page hosting it, the outer script can reach straight through iframe.contentDocument and edit the frame’s DOM. Click the button and watch a same-origin page rewrite the contents of a frame it doesn’t own:

interactiveReaching into a same-origin frame

Windows on subdomains: document.domain

Different domains mean different origins, by definition. But there’s a special case for sibling subdomains.

Suppose you run maya.site.com, raj.site.com, and site.com. They differ, yet they share the second-level domain site.com. You can tell the browser to overlook the subdomain difference and treat them as one origin for cross-window purposes.

Each participating window runs one line:

document.domain = 'site.com';

That’s the whole trick. Once every window has set the same value, they can talk freely. It only works when they already share that second-level domain — you can’t set document.domain to just anything.

Iframe: wrong document pitfall

This one isn’t about cross-origin security — it bites even on same-origin frames — but it trips people up constantly.

The moment an <iframe> element exists, it already carries a document. The problem: that initial document is not the one that ends up loading from src. The browser creates a throwaway blank document first, then replaces it once the real page arrives.

So anything you do to that first document gets thrown away with it:

<iframe src="/" id="iframe"></iframe>

<script>
  let oldDoc = iframe.contentDocument;
  iframe.onload = function() {
    let newDoc = iframe.contentDocument;
    // the loaded document is a different object from the initial one!
    alert(oldDoc == newDoc); // false
  };
</script>
on creation
oldDoc
temporary blank doc
loading src…
page fetched
after onload
newDoc
real document
oldDoc == newDoc → false. Work only with newDoc.
An iframe swaps its document mid-flight. Handlers set on the first one vanish.

Never touch the document of a not-yet-loaded iframe — it’s the wrong document, and event handlers you attach to it are silently discarded.

So when is the right document actually there?

The safe answer is iframe.onload: by the time it fires, the real document is in place. The downside is that onload waits for the whole frame plus every resource to finish.

If you need to catch the swap sooner, poll with setInterval and watch for the document reference to change:

<iframe src="/" id="iframe"></iframe>

<script>
  let oldDoc = iframe.contentDocument;

  // every 100 ms, check whether the document has been replaced
  let timer = setInterval(() => {
    let newDoc = iframe.contentDocument;
    if (newDoc == oldDoc) return;

    alert("New document is here!");

    clearInterval(timer); // got it, stop polling
  }, 100);
</script>

Collection: window.frames

iframe.contentWindow isn’t the only handle on a frame’s window. There’s also the named collection window.frames:

  • By index: window.frames[0] — the window of the first frame in the document.
  • By name: window.frames.iframeName — the window of the frame whose name="iframeName".

They point at the same object:

<iframe src="/" style="height:80px" name="win" id="iframe"></iframe>

<script>
  alert(iframe.contentWindow == frames[0]); // true
  alert(iframe.contentWindow == frames.win); // true
</script>

Frames can nest — an iframe can hold iframes of its own — and their window objects form a tree. Three properties let you climb it:

  • window.frames — the collection of direct child windows.
  • window.parent — the window one level up.
  • window.top — the window at the very top of the tree.
window.top(topmost page)
frames[0]
frames[0].frames[0]
frames[1]
From frames[0]: .parent → top, .top → top.
The window tree. frames goes down; parent goes up one level; top jumps to the root.

Which gives the identity:

window.frames[0].parent === window; // true

top is handy for detecting whether your page is running standalone or embedded in someone else’s frame:

if (window == top) { // is the current window the topmost one?
  alert('The script is in the topmost window, not in a frame');
} else {
  alert('The script runs in a frame!');
}

The “sandbox” iframe attribute

The sandbox attribute lets you strip capabilities from an <iframe> so it can host untrusted content without doing damage. It “sandboxes” the frame — forcing it into a foreign origin and switching off risky features.

There’s a strict default set of restrictions when you write <iframe sandbox src="..."> with no value. From there you relax it by listing the permissions you want to grant back, space-separated: <iframe sandbox="allow-forms allow-popups">.

So the mental model is inverted from what you might expect: empty sandbox locks everything down, and each token you add lifts one specific restriction.

sandbox=“”
no scripts · no forms · no popups · treated as foreign origin · can’t navigate top
+ allow-* →
each token
lifts exactly one restriction back — nothing more
sandbox starts fully locked; each allow-* token opens one door back up.

Some of the tokens:

allow-same-origin — By default sandbox forces the “different origin” treatment on the frame, so even a frame whose src points at your own site is treated as foreign, with all the script restrictions that implies. This token removes that forcing, restoring the frame’s real origin.

allow-top-navigation — Lets the frame change parent.location (navigate the outer page).

allow-forms — Lets the frame submit forms.

allow-scripts — Lets the frame run its own scripts.

allow-popups — Lets the frame open popups via window.open.

See the iframe reference on MDN for the complete list.

The demo below loads the same little page — some JavaScript that tries to announce itself, plus a form — into a frame twice. Toggle the sandbox on and off. With the bare default set (<iframe sandbox src="...">), the script never runs and the form is inert; that is how harsh the default really is.

interactiveThe default sandbox switches everything off

Cross-window messaging

postMessage is the sanctioned way for windows to exchange data regardless of their origins. It’s the door through the Same Origin wall.

A page on maya-vance.com can hand data to a page on gmail.com — but only because both sides opted in by calling the right functions. Nobody eavesdrops; both parties have to participate. That’s what keeps it safe.

The API has two halves: the sender’s postMessage call, and the receiver’s message event handler.

postMessage

The sender calls postMessage on the receiving window. To message the window win, you call win.postMessage(data, targetOrigin).

data — Whatever you want to send. It can be any object; the browser copies it using the structured clone algorithm, so you’re not sharing a live reference — the receiver gets its own copy.

targetOrigin — Restricts delivery to a window that currently holds a document from that exact origin. A safety gate.

Why the gate matters: remember you can’t read a cross-origin window’s location. So you genuinely don’t know which site is loaded in the target right now — the user might have navigated away, and your window has no way to tell. targetOrigin says “only deliver this if the receiver is still on the origin I expect.” When the payload is sensitive, that check is the difference between handing your secret to the right site and handing it to whatever page slipped into that window.

Here win only gets the message if it’s showing a document from http://example.com:

<iframe src="http://example.com" name="example">

<script>
  let win = window.frames.example;

  win.postMessage("message", "http://example.com");
</script>

Drop the check by passing * — deliver to whoever is there. Only do this when the data isn’t sensitive:

<iframe src="http://example.com" name="example">

<script>
  let win = window.frames.example;

  win.postMessage("message", "*");
</script>
sender window
win.postMessage(
  data,
  targetOrigin
)
→ origin gate →
deliver only if receiver’s origin == targetOrigin
(or targetOrigin is *)
receiver window
message event
fires with
{data, origin, source}
postMessage flow: the origin gate runs on the sender's side before the receiver's handler fires.

onmessage

On the receiving side, listen for the message event. It fires when someone calls postMessage at your window and the targetOrigin check passes.

The event object carries three properties worth knowing:

data — The payload from postMessage.

origin — The origin of the sender, for example http://example.com. This one you can trust: the browser fills it in, the sender can’t fake it. Always check it before trusting the data.

source — A reference to the sender’s window. Call source.postMessage(...) to reply immediately.

One gotcha: you must register the handler with addEventListener. The shorthand window.onmessage = ... does not work for this event.

window.addEventListener("message", function(event) {
  if (event.origin != 'http://example.com') {
    // came from a domain we don't trust — ignore it
    return;
  }

  alert( "received: " + event.data );

  // reply with event.source.postMessage(...) if you like
});

The full working example is below. The frame runs a message listener that echoes whatever it receives; the outer page sends through frame.contentWindow.postMessage and prints the frame’s reply. Type something and hit Send — the round trip is real postMessage traffic.

interactiveA postMessage round trip

Summary

To reach into another window’s methods or content, you first need a reference to it.

For popups:

  • From the opener: window.open opens a new window and returns a reference to it.
  • From the popup: window.opener points back at the window that opened it.

For iframes, navigate the window tree with:

  • window.frames — the collection of nested child windows.
  • window.parent and window.top — the parent window and the topmost one.
  • iframe.contentWindow — the window inside a given <iframe> element.

When windows share an origin (protocol, host, and port), they can do anything to each other.

Otherwise, only two actions are open:

  • Write the other window’s location (redirect it — write-only, no reading back).
  • Post a message to it.

Two ways to relax the origin barrier:

  • Windows on the same second-level domain, like a.site.com and b.site.com, become “same origin” once both run document.domain='site.com'.
  • An <iframe sandbox> is forced into a foreign-origin state unless allow-same-origin is present. This lets you run untrusted code in a frame even when it’s served from your own site.

And postMessage connects windows across any origins at all:

  1. The sender calls targetWin.postMessage(data, targetOrigin).

  2. Unless targetOrigin is '*', the browser confirms targetWin’s origin matches targetOrigin before delivering.

  3. On a match, targetWin receives a message event carrying:

    • origin — the sender’s origin, e.g. http://my.site.com.
    • source — a reference back to the sender’s window.
    • data — the copied payload, any structured-cloneable object.

    Register the handler with addEventListener("message", ...), and validate origin before you trust anything.