JavaScript postMessage Table
| Piece | What it does | Field note |
|---|---|---|
window.postMessage(msg, targetOrigin) | The send | Cross-realm fire-and-forget; post at contentWindow, not the iframe element |
message event | The receive | e.origin (sender), e.data (cloned), e.source (reply window) - validate BEFORE trusting |
e.source | The reply channel | e.source.postMessage(reply, e.origin) answers the exact sender, no stored refs |
structured clone | What crosses | Data copied, not shared - functions/prototypes/DOM do not survive; Maps/Blobs do |
targetOrigin | The delivery gate | Browser delivers only if the receiver's origin matches the exact string you named |
'*' + secrets | The classic leak | Wildcard delivers tokens/user data to ANY embedder - whatever iframed you today |
iframe <-> parent | The main scene | Responsive embeds, SSO popups, widget bridges - validate origin, version payloads |
vs BroadcastChannel | The sibling | postMessage = point-to-point across origins; BroadcastChannel = same-origin pub/sub |
postMessage is the only sanctioned channel between two realms that may not trust each other: window to iframe, iframe to parent, opener to popup, your page to a third-party widget. The payload travels asynchronously and arrives as a structured clone - which means it works across origins, where every other direct object reference is blocked by the same-origin policy.
Bottom line: the browser enforces exactly one thing for you - targetOrigin gates DELIVERY, meaning the message only arrives if the receiving window's origin matches the string you named. Everything else is your job: authenticate the sender by checking e.origin against an allowlist before trusting e.data, reply through e.source instead of assuming window.parent, and version your payload shapes so an old embedder cannot misread a new message. The browser moves the mail; it does not check the sender's ID.
The honest part: two failure modes dominate production incidents. The wildcard leak - posting tokens or user data with targetOrigin '*' delivers your secrets to whatever page currently embeds you, which after a redirect or an open redirect chain is not necessarily your partner. And the missing protocol - postMessage has no return value, so teams bolt on globals or polling instead of a 10-line request-id convention. Both are architecture decisions made once, on day one.
How to use
- Send with an exact origin: iframeEl.contentWindow.postMessage({ type: 'resize', height: 900 }, 'https://embed.partner.com') - the full scheme-plus-host string, never '*' when the payload matters. Note you post to contentWindow (the window), not the iframe element itself.
- Receive with an allowlist: window.addEventListener('message', (e) => { if (e.origin !== 'https://embed.partner.com') return; handle(e.data); }) - the check is equality against a known origin, not a substring test (containment checks like .includes('partner.com') are defeated by partner.com.evil.io).
- Reply through the event: e.source.postMessage({ type: 'resized' }, e.origin) - event.source is the window that sent you the message and e.origin is where it claims to live; replying there needs no stored window references and cannot reach the wrong frame.
- Build request/response on ids: postMessage returns nothing, so put an id in the request and correlate it in the reply - { type: 'get-height', id: 42 } answered by { type: 'height', id: 42, value: 900 } - then resolve a stored Promise per id. Ten lines that replace every global-variable bridge ever written.
- Know the main scenes: responsive embeds (iframe reports scrollHeight up, parent sets height), OAuth/SSO popup handoffs (opener receives the token message - which is why the redirect URI and origin checks are security-critical), editor/widget bridges, and worker-to-window relays. Each scene is the same loop: validate origin, version payload, reply to source.
Frequently asked questions
Is postMessage secure?
It is as secure as your e.origin check, and no more. Delivery is gated by targetOrigin (you choose who may RECEIVE), but receiving is not: any page can post a message at your window with whatever shape it likes, so the sender side of trust lives entirely in your allowlist-plus-schema validation. The two classic failures both skip that: trusting e.data because 'it must be our widget', and sending secrets with '*' - which hands the payload to any embedder, including a lookalike that iframed you. Post with exact origins, receive with exact allowlists, and treat e.data as hostile input even when it looks familiar.
postMessage vs BroadcastChannel vs the storage event - which when?
Choose by trust boundary, not features. Different origins (your page and a partner widget, a popup you opened): postMessage - it is the only cross-origin option, point-to-point, needs a window reference (contentWindow, opener, or e.source). Same origin, different tabs (a form preview, multi-tab logout): BroadcastChannel - pub/sub across all tabs of the origin with zero window references and no origin validation to write. The storage event: same-origin tab sync too, but stringly-typed through localStorage with awkward semantics - use it only if you already persist that state. Cross-origin and same-origin are different problems wearing similar APIs.
Can I send DOM nodes, functions, or class instances?
No - the payload is structured-clone copied, not shared: functions, DOM nodes, prototypes, and error-bearing circular class graphs do not survive; Maps, Sets, Dates, ArrayBuffers, Blobs, and plain data do (Errors clone on modern engines too). The working pattern for richer objects: send a description and rebuild - { tag: 'img', src: url } becomes an element on the far side through the receiver's own factory. This is a feature: the serialization boundary means a compromised frame cannot hand you a live object with methods - you get inert data you get to interpret.
Why is my iframe not receiving messages?
Four suspects in frequency order. Timing: the iframe has not loaded its listener yet - post from the parent on iframe load, or have the child announce readiness up first. Wrong handle: messages go to iframe.contentWindow, not the iframe element - posting at the element silently does nothing. Origin mismatch: targetOrigin is an exact string - a trailing slash, http vs https, or a port difference all fail delivery silently; log e.origin on the receiving side and compare byte for byte. Sandboxing: a sandboxed iframe without allow-scripts cannot run the listener at all. There is no delivery error channel - silence is the failure mode, so instrument both ends before debugging the protocol itself.