Checked on 2026-08-24.
Put a cross-origin page in an iframe and you get back two handles that don't behave alike. frame.contentWindow hands you an object. frame.contentDocument hands you null — not an exception, just null, because the HTML Standard's algorithm returns null unless the two documents are same origin-domain. The object you did get is a WindowProxy, and nearly everything you'd read on it throws.
That's the same-origin policy doing its job, and why every real integration with a frame you don't own runs through one function.
What survives on a cross-origin window
The allowlist is written out in the specification. CrossOriginProperties returns exactly this set for a Window: window, self, location, close, closed, focus, blur, frames, length, top, opener, parent, and postMessage, plus array indexes. Ask for almost anything else — document, name, a global the framed script defined — and you'll get a SecurityError DOMException; only then and three well-known symbols get undefined instead.
It's stingier than it reads. location is on the list, but a cross-origin Location exposes only href and replace — and href has a setter and no getter. You can send a frame somewhere. You can't ask where it went.
The HTML Standard makes it concrete in an example about a framed contacts provider. The outer page "cannot access anything inside the first iframe"; it can only navigate the frame, resize it, and "Send a message event to the Window in the iframe using the window.postMessage() API." Three verbs, and one carries data. Not that there's much to talk to: of the 60 apps we measured, 7 allow framing at all.
The signature, and the argument people leave off
Two overloads on Window:
targetWindow.postMessage(message, targetOrigin, transfer);
targetWindow.postMessage(message, options);
The options object takes targetOrigin and transfer, and the IDL default for targetOrigin is "/" — one slash, which the algorithm swaps for the caller's own origin. The specification puts it plainly: "This default restricts the message to same-origin targets only." So postMessage(data) alone won't reach a cross-origin frame.
targetOrigin isn't a filter you add for tidiness. It's an address, and the browser checks it before delivering. The specification: "If the origin of the target window doesn't match the given target origin, the message is discarded, to avoid information leakage."
Two details are easy to miss. The comparison runs inside a queued task, not during your call, so it uses wherever the frame is when the message lands. And a targetOrigin that's neither * nor / goes through the URL parser: a string that doesn't parse throws SyntaxError synchronously.
Two checks, and neither is optional
On send: no '*' with anything private
The wildcard skips the address check. The HTML Standard's advice is blunt: "Authors should not use the wildcard keyword (*) in the targetOrigin argument in messages that contain any confidential information, as otherwise there is no way to guarantee that the message is only delivered to the recipient to which it was intended."
That's because a WindowProxy follows the frame, not the document in it. MDN states the attack: "A malicious site can change the location of the window without your knowledge, and therefore it can intercept the data sent using postMessage." Your handle stays valid across that navigation. With '*', so does your message.
On receipt: compare event.origin exactly
Anything that can reach your window can post to it, and the event looks the same whoever sent it. The HTML Standard tells authors to "check the origin attribute to ensure that messages are only accepted from domains that they expect to receive messages from." MDN says it harder: "This cannot be overstated: Failure to check the origin and possibly source properties enables cross-site scripting attacks."
Compare with !== against a constant; startsWith accepts https://example.com.evil.test, and includes is worse:
if (event.origin !== 'https://widget.example') return;
Then check the payload: a trusted origin can still be compromised. The specification wants both: "even after checking the origin attribute, authors should also check that the data in question is of the expected format."
Two footnotes. The value is the sender's origin when postMessage ran: MDN warns that "this origin is not guaranteed to be the current or future origin of that window, which might have been navigated to a different location since postMessage was called." And a frame under a sandbox attribute without allow-same-origin has an opaque origin, which serializes as the four-character string null. That isn't an identity; accepting it is accepting anyone.
What actually crosses: the structured clone algorithm
The message isn't JSON and it isn't a reference. It's run through structured serialization, which handles more than JSON and less than you'd hope.
It carries primitives other than Symbol, plus Date, RegExp, Map, Set, Error, ArrayBuffer, typed arrays, Blob and File. Cycles survive: the algorithm keeps a memory map of what it's visited.
It refuses more than people expect, and loudly, with a DataCloneError DOMException. Functions throw. So do symbols, proxies and DOM nodes — MDN: "Cloning DOM nodes likewise throws a DataCloneError exception." So does anything with an internal slot the algorithm doesn't know, which is why Promise and WeakMap fail.
What arrives is often thinner than what you sent. MDN: "The prototype chain is not walked or duplicated." A class instance turns up as a plain object with no methods. And "Property descriptors, setters, getters, and similar metadata-like features are not duplicated" — a getter is read once and its result copied, and a read-only property arrives writable.
One failure mode lands on the other side. If the receiving realm can't deserialize what you sent — a platform object whose interface isn't exposed there, say — it fires messageerror instead of message. Nothing comes back to you, and if nobody's listening, that message vanishes.
MessageChannel, for a busier connection
new MessageChannel() gives you two entangled ports. Post one into the frame, keep the other, and everything after travels port to port.
A MessagePort has no targetOrigin parameter, and that's the design, not a gap: the port is the credential. You establish who holds it once, with the targetOrigin on the postMessage that delivered it. Setting port.onmessage starts the flow; with addEventListener you'll have to call port.start() yourself.
The same transfer list moves transferable objects instead of copying them — ArrayBuffer, MessagePort, ReadableStream and OffscreenCanvas, among others. MDN lists fifteen and says even that may not be exhaustive. The HTML Standard is clear about the price: "Transferring is an irreversible and non-idempotent operation. Once an object has been transferred, it cannot be transferred, or indeed used, again."
The list is instructions, not cargo. MDN: "the transferable array only indicates how certain resources should be sent, but does not actually send them (although they would always be detached)." The object must be in the message too, or it arrives nowhere — unless it's a MessagePort. The specification builds the event's ports array from the transferred values, so a port arrives there whether or not the message names it, as the example below does.
A request and a reply, end to end
The frame speaks first. A message posted into a frame that hasn't attached its listener yet is lost, so the specification advises "to have the child Document post a message to their parent announcing their readiness to receive messages, and for the parent to wait for this message before beginning posting messages."
That moves the race rather than removing it. Attach the page's listener before you insert the frame, or ready arrives with nobody listening — and nothing times out, because nothing started.
Inside the frame, at https://widget.example:
const PARENT = 'https://app.example';
addEventListener('message', (event) => {
if (event.origin !== PARENT) return;
if (event.source !== parent) return;
const port = event.ports[0];
if (!port) return;
if (event.data?.method === 'height') {
port.postMessage({ ok: true, height: document.documentElement.scrollHeight });
} else {
port.postMessage({ ok: false });
}
port.close();
});
parent.postMessage({ type: 'ready' }, PARENT);
In the page, at https://app.example:
const WIDGET = 'https://widget.example';
const frame = document.querySelector('iframe');
function ask(method) {
return new Promise((resolve, reject) => {
const { port1, port2 } = new MessageChannel();
const timer = setTimeout(() => {
port1.close();
reject(new Error('no answer'));
}, 5000);
port1.onmessage = (event) => {
clearTimeout(timer);
port1.close();
if (event.data?.ok) resolve(event.data);
else reject(new Error('refused'));
};
frame.contentWindow.postMessage({ method }, WIDGET, [port2]);
});
}
addEventListener('message', async (event) => {
if (event.origin !== WIDGET) return;
if (event.source !== frame.contentWindow) return;
if (event.data?.type !== 'ready') return;
try {
const { height } = await ask('height');
frame.style.height = `${height}px`;
} catch (error) {
console.warn('widget height request failed', error);
}
});
Four things there are load-bearing. Both sides compare event.origin with a constant. Both check event.source, which rules out a different frame sharing that origin. The reply comes back on a port nobody else was handed. And the request times out, because a frame that never answers would leave the promise pending.
What document.domain used to do
Before all this, pages on a.example.com and b.example.com could both set document.domain = 'example.com' and become same origin-domain. The specification still describes the effect: it "enables pages on different hosts of a domain to synchronously access each other's DOMs."
It also drops the port from the comparison, so anything else on the host gets that access too. The HTML Standard now says the feature "is in the process of being removed from the web platform. (This is a long process that takes many years.)" and names the replacement: "Instead, use postMessage() or MessageChannel objects to communicate across origins in a safe manner."
Removal is happening through agent clusters, not a delete. Per the specification, the setter stays, but does nothing on an origin-keyed page: "In cases where crossOriginIsolated or originAgentCluster return true, the setter will do nothing." A page asks for that with Origin-Agent-Cluster: ?1.
Here specification and browser have parted company. The standard still treats origin-keying as opt-in. Its rule for an agent cluster key: "Without web developer action to achieve origin-keyed agent clusters, it will be a site." So by the letter of it, a page sending no header keeps a settable document.domain. Chrome inverted that default. Its enterprise policy definition, read on 2026-09-04, describes the shipped behavior: "By default, documents may be put in origin-keyed agent clusters. document.domain is not settable for such documents." Chrome's announcement dates the change to Chrome 115 and warns the setter "will not throw an exception. It will only cease to have an effect." Chromium's source agrees, logging document.domain mutation is ignored because the surrounding agent cluster is origin-keyed. to the console.
Two caveats. A site that can't move yet can send Origin-Agent-Cluster: ?0 for site-keyed clusters, which Chrome documents as a last resort. And it's Chrome's change: MDN's compatibility data notes nothing equivalent for Firefox or Safari, so the setter may still work there. Treat it as gone anyway.
Questions
Do I need postMessage for a same-origin frame?
No. Same-origin frames reach into each other's documents directly, so a function call is simpler and synchronous. Messaging is still worth choosing if the frame might move to another origin later: the seam is already cut.
Can I make a page accept messages its author didn't plan for?
No. Delivery fires a message event in the other document, and if nothing there listens, nothing happens. You can't install a listener in a cross-origin frame — that's what the allowlist above is for.
Is postMessage synchronous?
No. Your call returns as soon as the message is serialized; the event fires later, once the current task finishes. What you send is a copy taken at call time, so mutating it afterward changes nothing at the far end. Errors in the receiving handler stay there, and you'll never see them.