testiframe.com

How to Communicate Between an Iframe and Its Parent with postMessage

Working code for both directions, the checks that keep it secure, and the patterns that stop messages from going missing.

Published · 10 min read
Quick answer

Use window.postMessage to talk across origins. The parent calls iframe.contentWindow.postMessage(data, targetOrigin) and the iframe calls window.parent.postMessage(data, targetOrigin), each passing the exact origin of the receiver. Each side listens for the message event on its own window and checks event.origin and event.source before trusting event.data.

Why can't the parent access a cross-origin iframe?

An origin is the scheme, host and port of a URL. https://example.com, https://app.example.com and http://example.com are three different origins.

When the parent and the iframe share an origin, they can reach into each other freely: iframe.contentDocument, iframe.contentWindow.someFunction(), window.parent.document. When they don't, the same-origin policy blocks it. contentDocument returns null, and reading contentWindow.document throws a SecurityError. Only a short list of properties stays usable across origins, such as postMessage, parent, top, frames, focus() and setting location.

Without that rule, any page could frame your bank and read your balance. postMessage is the sanctioned channel through the wall: both sides opt in, and the receiver decides what to trust. It's also how you handle tasks like resizing a cross-origin iframe to its content, because the parent can't measure the child itself.

How to send messages between an iframe and its parent

The call is targetWindow.postMessage(message, targetOrigin). The browser delivers the message only if targetWindow is currently showing a document from targetOrigin; otherwise it silently drops it. "*" means any origin, and "/" means the sender's own origin.

Parent to iframe

<iframe id="child" src="https://widget.example.net/embed" title="Chat widget"></iframe>

<script>
  const CHILD_ORIGIN = 'https://widget.example.net';
  const frame = document.getElementById('child');

  frame.addEventListener('load', () => {
    frame.contentWindow.postMessage({ type: 'set-theme', theme: 'dark' }, CHILD_ORIGIN);
  });
</script>

Inside the iframe, listen on the iframe's own window:

// https://widget.example.net/embed
const ALLOWED_PARENTS = ['https://www.example.com'];

window.addEventListener('message', (event) => {
  if (event.source !== window.parent) return;
  if (!ALLOWED_PARENTS.includes(event.origin)) return;
  if (event.data?.type === 'set-theme') {
    document.documentElement.dataset.theme = event.data.theme === 'dark' ? 'dark' : 'light';
  }
});

Iframe to parent

// Inside the iframe
window.parent.postMessage({ type: 'cart-updated', count: 3 }, 'https://www.example.com');
// On the parent page
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://widget.example.net') return;
  if (event.source !== frame.contentWindow) return;
  if (event.data?.type === 'cart-updated') {
    document.querySelector('#cart-count').textContent = String(event.data.count);
  }
});

window.parent is the frame directly above. If your iframe is nested inside another iframe, use window.top to reach the top-level page.

What data can you send?

Messages are copied with the structured clone algorithm, not shared. Most data survives the copy; code and live objects don't.

ValueResult
Strings, numbers, booleans, null, plain objects, arraysCopied (circular references are kept)
Date, RegExp, Map, SetCopied
ArrayBuffer, typed arrays, Blob, FileCopied, or transferred with the transfer list
Class instancesArrive as plain objects; methods and prototype are lost
Functions, DOM nodes, symbolsThrow a DataCloneError

For large binary data, postMessage(buffer, origin, [buffer]) transfers the ArrayBuffer instead of copying it; it becomes unusable on the sending side. You don't need JSON.stringify: plain objects are easier to validate.

How to receive and validate postMessage events

Any window that holds a reference to yours can post to it, from any origin. Every listener should run three checks before it acts:

  1. Origin: compare event.origin to an allowlist with exact string equality. Never use includes, startsWith or an unanchored regex: https://example.com.attacker.net passes all three.
  2. Source: compare event.source to the window you expect, such as frame.contentWindow. This tells two iframes from the same origin apart.
  3. Shape: require an object with a known type, validate the fields that type needs, and ignore everything else.
const ALLOWED_ORIGINS = new Set([
  'https://widget.example.net',
  'https://widget-staging.example.net',
]);

const handlers = {
  'acme:cart-updated'(data) {
    if (!Number.isInteger(data.count) || data.count < 0) return;
    cartBadge.textContent = String(data.count);
  },
};

window.addEventListener('message', (event) => {
  if (!ALLOWED_ORIGINS.has(event.origin)) return;
  if (event.source !== frame.contentWindow) return;

  const data = event.data;
  if (typeof data !== 'object' || data === null) return;
  if (typeof data.type !== 'string' || !Object.hasOwn(handlers, data.type)) return;

  handlers[data.type](data);
});

Object.hasOwn stops a message with type: "toString" from reaching inherited properties, and a prefix such as acme: keeps your message types from colliding with other scripts on the page.

Treat event.data as untrusted input

Even from an allowed origin, never pass message data to innerHTML, eval, new Function or location.href without validating it. An XSS bug on the other origin would otherwise become one on yours.

How to wait until the iframe is ready

The iframe's load event tells you the document loaded, not that its app has attached a message listener. A single-page app might set that up a second later. Messages posted before then are lost, and nothing tells you. Posting even earlier fails too: the frame still holds its initial about:blank document, so an exact targetOrigin doesn't match.

The fix is a handshake. The child announces that it's ready once its listener exists, and the parent queues anything it wants to send until then.

// Inside the iframe
const ALLOWED_PARENTS = ['https://www.example.com', 'https://app.example.com'];
let parentOrigin = null;

window.addEventListener('message', (event) => {
  if (event.source !== window.parent) return;
  if (!ALLOWED_PARENTS.includes(event.origin)) return;
  if (event.data?.type === 'init') {
    parentOrigin = event.origin; // use this as targetOrigin from now on
    applyConfig(event.data.config);
  }
});

// The listener exists, so announce it. No data in it, so "*" is acceptable here.
window.parent.postMessage({ type: 'ready' }, '*');
// On the parent page: attach the listener before the iframe can load
const CHILD_ORIGIN = 'https://widget.example.net';
const frame = document.createElement('iframe');
let ready = false;
const outbox = [];

function send(msg) {
  if (ready) frame.contentWindow.postMessage(msg, CHILD_ORIGIN);
  else outbox.push(msg);
}

window.addEventListener('message', (event) => {
  if (event.origin !== CHILD_ORIGIN || event.source !== frame.contentWindow) return;
  if (event.data?.type === 'ready') {
    ready = true;
    frame.contentWindow.postMessage({ type: 'init', config: { theme: 'dark' } }, CHILD_ORIGIN);
    outbox.splice(0).forEach((msg) => send(msg));
  }
});

frame.src = CHILD_ORIGIN + '/embed';
frame.title = 'Chat widget';
document.body.append(frame);

send({ type: 'set-locale', locale: 'en-GB' }); // queued until "ready"

The child posts ready with "*" because it doesn't know the parent's origin yet, which is fine for a message that carries nothing. It learns the origin from the parent's init, checked against its allowlist, and uses it for everything after that. If the child reloads, it sends ready again and the parent re-sends init.

Watch your iframe's postMessage traffic

Load your page in the tester and open the Console tab to see every message it posts to its parent, and send test messages back.

How to build request/response messaging with promises

postMessage is fire-and-forget. When the parent needs an answer, such as the child's current form state, give each request an id, keep the promise's resolve in a map, and settle it when a reply with the same id comes back. Add a timeout so a missing reply doesn't hang forever.

// Parent
function createRpc(targetWindow, targetOrigin) {
  let nextId = 1;
  const pending = new Map();

  window.addEventListener('message', (event) => {
    if (event.origin !== targetOrigin || event.source !== targetWindow) return;
    const msg = event.data;
    if (msg?.type !== 'rpc:response' || !pending.has(msg.id)) return;
    const { resolve, reject, timer } = pending.get(msg.id);
    clearTimeout(timer);
    pending.delete(msg.id);
    if (msg.error) reject(new Error(msg.error));
    else resolve(msg.result);
  });

  return function call(method, params, timeoutMs = 5000) {
    const id = nextId++;
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error('Timed out waiting for ' + method));
      }, timeoutMs);
      pending.set(id, { resolve, reject, timer });
      targetWindow.postMessage({ type: 'rpc:request', id, method, params }, targetOrigin);
    });
  };
}

const call = createRpc(frame.contentWindow, CHILD_ORIGIN);
call('getFormState').then((state) => console.log(state));
// Child
const methods = {
  getFormState: () => Object.fromEntries(new FormData(document.querySelector('form'))),
  setTheme: ({ theme }) => { document.documentElement.dataset.theme = String(theme); },
};

window.addEventListener('message', async (event) => {
  if (event.source !== window.parent || !ALLOWED_PARENTS.includes(event.origin)) return;
  const msg = event.data;
  if (msg?.type !== 'rpc:request' || !Object.hasOwn(methods, msg.method)) return;
  const reply = { type: 'rpc:response', id: msg.id };
  try {
    reply.result = await methods[msg.method](msg.params ?? {});
  } catch (err) {
    reply.error = String(err?.message ?? err);
  }
  event.source.postMessage(reply, event.origin);
});

The child exposes only the methods in its map and replies to the exact origin that asked. Results must be cloneable, so getFormState returns a plain object, not the FormData.

How to use MessageChannel for a private connection

A MessageChannel gives you two linked ports. Keep one, transfer the other into the iframe with a single validated postMessage, and from then on the two sides talk over the ports. Nothing else on the page sees that traffic, and you don't need to filter noise on window.

// Parent: a fresh channel for every document the frame loads
let port = null;

frame.addEventListener('load', () => {
  const channel = new MessageChannel();
  port = channel.port1;
  port.onmessage = (event) => console.log('from iframe:', event.data);
  frame.contentWindow.postMessage({ type: 'connect' }, CHILD_ORIGIN, [channel.port2]);
});
// Child
window.addEventListener('message', (event) => {
  if (event.source !== window.parent || !ALLOWED_PARENTS.includes(event.origin)) return;
  if (event.data?.type !== 'connect' || !event.ports[0]) return;
  const port = event.ports[0];
  port.onmessage = (e) => handle(e.data);
  port.postMessage({ type: 'connected' });
});

Run the origin check when you accept the port; messages that arrive on a port have an empty event.origin. Setting onmessage starts the port automatically, but if you use addEventListener instead, call port.start(). A port can only be transferred once, which is why the parent creates a new channel on every load. For single-page apps, send the port in reply to the child's ready message instead.

postMessage security checklist

  • Specific targetOrigin. Never send tokens, personal data or anything sensitive with "*". If the iframe navigates to another site, "*" hands the data to that site.
  • Exact origin allowlist on every listener, compared with === or a Set.
  • Check event.source against the specific window you expect.
  • Validate the message shape and ignore unknown types.
  • Never execute or render raw message data as HTML, script or a navigation URL.
  • Never allowlist "null". Sandboxed frames, data: URLs and other opaque origins all report it, so it identifies nobody. See the iframe sandbox attribute guide for why sandboxed frames get it.
  • Don't pass session tokens if you can avoid it. Let the iframe authenticate itself; iframe cookie rules explain what works across browsers.
  • Control who can frame the child. Send CSP frame-ancestors so a hostile page can't embed your widget and talk to it. See X-Frame-Options vs frame-ancestors and clickjacking protection.
  • Clean up. In single-page apps, remove listeners when the iframe goes away, for example with an AbortController signal passed to addEventListener.

How to debug iframe postMessage

Start by seeing what's actually on the wire. The testiframe.com iframe tester frames your page and its Console tab logs load events and every message the framed page posts to its parent, with the sender's origin. The input under the log posts a message into the frame: valid JSON such as {"type":"ping"} is sent as an object, anything else as a string.

The tester is the parent here, so its origin is https://testiframe.com. Add that to your child's allowlist in a development build, or the child will correctly ignore it. The tester sends with "*", so its messages reach your page whatever origin it's on. Messages your child sends with a targetOrigin of your production site won't show up, which is itself a quick check that your origin logic works.

In browser DevTools:

  • Chrome and Firefox both let you switch the console's JavaScript context to the iframe, so you can run code on the child's side.
  • In Chrome's console, monitorEvents(window, 'message') logs every message event the selected window receives.
  • Temporarily log event.origin and event.data at the top of your listener, before any filters, to see what's arriving and why it's rejected.

Common postMessage bugs and how to fix them

SymptomLikely causeFix
Message never arrivesWrong targetOrigin: http vs https, www vs apex, a different dev port, or the iframe redirected to another originUse the exact origin the iframe ends up on. Chrome logs a console error when the origins don't match.
Listener rejects a valid messageAllowlist entry has a trailing slash or pathevent.origin never has one: https://example.com
First messages lostSent before the child's listener existedUse the ready handshake and queue messages
SecurityError when adding a listenerListening on frame.contentWindow instead of your own windowEach side listens on its own window
Child receives its own messagesCalled window.postMessage instead of window.parent.postMessagePost to window.parent (or window.top when nested)
Errors from unrelated messagesBrowser extensions, ad frames and other libraries post to the same windowFilter by origin, source and type before reading fields
Can't reach a sandboxed iframeIts origin is opaque ("null"), so no specific targetOrigin matchesSend with "*" and no sensitive data, and identify it by event.source

FAQ

How do I send data from an iframe to the parent page?

Call window.parent.postMessage(data, 'https://parent.example.com') inside the iframe, using the parent's exact origin. On the parent, listen with window.addEventListener('message', ...) and check event.origin and event.source before using event.data.

Why is my postMessage not being received?

The usual causes are a targetOrigin that doesn't exactly match the receiver's origin, a message sent before the other side attached its listener, or a listener on the wrong window. Check the console for origin mismatch errors and use a ready handshake so nothing is sent too early.

Is it safe to use "*" as the targetOrigin?

Only for messages that contain nothing sensitive, such as a ready signal. With "*" the browser delivers the message to whatever page the target window is showing, even if it has navigated to another site. For anything else, pass the exact origin.

Can a parent page read the content of a cross-origin iframe?

No. The same-origin policy blocks access to a cross-origin iframe's DOM, so contentDocument is null. The framed page has to send the data you need with postMessage, and only if its code chooses to.

What is the difference between postMessage and MessageChannel?

window.postMessage sends to a whole window, where any script on the page can listen. A MessageChannel creates two linked ports; you transfer one to the iframe with a single postMessage, then talk privately over the ports without filtering other window messages.