ESC

Type to search the knowledge base.

postMessage Security

Secure window.postMessage: targetOrigin, event.origin checks, schema validation, and iframe bridges.

advanced3 min read
  • security
  • postmessage
  • iframe
  • xss
  • origin

postMessage is the browser’s structured way for windows/iframes to talk across origins. Default sample code on the internet is often wrong: '*' targets and missing event.origin checks turn the API into a cross-origin XSS primitive.

Docs: MDN postMessage, OWASP HTML5 Security.

Send with an explicit target

// BAD
otherWindow.postMessage(payload, '*');

// GOOD
otherWindow.postMessage(payload, 'https://widget.example');

targetOrigin ensures the browser delivers only if the recipient window is still that origin (important if it navigated).

Receive with origin + source checks

window.addEventListener('message', (event) => {
  if (event.origin !== 'https://widget.example') return;
  if (event.source !== expectedWindowRef) return; // when applicable

  const data = event.data;
  if (!data || data.type !== 'CHECKOUT_COMPLETE') return;
  if (typeof data.orderId !== 'string') return;

  handleOrder(data.orderId);
});

Never:

// BAD
eval(event.data);
el.innerHTML = event.data.html;

Capabilities to protect

Message does… Risk if untrusted
Changes auth state Account takeover
Injects HTML XSS
Redirects Open redirect / phishing
Forwards to privileged API CSRF-like actions

Treat messages as untrusted input equal to URL params.

Iframe embeds

const child = iframe.contentWindow;
child.postMessage({ type: 'INIT', theme: 'dark' }, 'https://child.example');

Combine with sandbox and Permissions-Policy — sandboxing iframes.

Wildcard origins product pressure

“We embed on many customer domains” → you still must not use '*' for sensitive data. Options: bootstrap with a per-customer origin config, or have the child report event.origin only after proving possession of a nonce from server-rendered embed code.

Interview out-loud

“postMessage needs explicit targetOrigin on send and strict event.origin (and schema) checks on receive. Never eval message data or assign it to HTML sinks. Wildcard * is for non-sensitive demos only.”

Reviewer prompts

  • What is the asset (session, PII, money movement)?
  • What is the attacker capability (web, XSS, network, dependency)?
  • Which control fails closed if misconfigured?
  • Is the server still enforcing authz?
  • Any new third party, iframe, or URL sink?

Residual risk note

Browser controls reduce likelihood and impact; they do not eliminate bugs. Prefer defense in depth: safe defaults in code, strict headers, and monitoring (CSP reports, auth anomaly alerts). When product pressure weakens a control, write down the accepted risk and a revisit date.

Protocol versioning

const ALLOWED = new Set(['https://pay.example']);
window.addEventListener('message', (e) => {
  if (!ALLOWED.has(e.origin)) return;
  if (e.data?.v !== 1) return;
  switch (e.data.type) {
    case 'READY':
      break;
    case 'RESULT':
      if (typeof e.data.payload?.id === 'string') apply(e.data.payload);
      break;
    default:
      break;
  }
});

Version bumps let you migrate without accepting ambiguous legacy shapes forever.

Further depth

Teams often under-invest in this topic until an incident or CWV regression. Schedule a one-hour drill: reproduce the failure mode in DevTools, list the top three mitigations for your stack, and file tickets with owners. Revisit after the next major feature that touches networking, rendering, auth, or third parties — those are the moments regressions land. Keep primary documentation links in the runbook so on-call is not searching chat history at 2am.

Concrete artifacts to leave behind: a short architecture note, a CI assertion or header snapshot, and a dashboard panel (lab or field) that would have caught the last bug. Teaching the rest of the team the mental model matters as much as the one-line fix.

Further reading

Related guides