← All news
security4 min read

A postMessage listener that skips the origin check will trust literally anyone

By Seaworthy · August 23, 2026

A golden padlock resting on a laptop keyboard, representing web security and HTTPS encryption

Photo by Towfiqu barbhuiya on Unsplash

A payment widget embedded in an iframe needs to tell the parent page when checkout finishes, and postMessage is the standard way to talk across that boundary:

window.addEventListener('message', (event) => {
  if (event.data.type === 'checkout-complete') {
    updateOrderStatus(event.data.orderId, 'paid')
  }
})

It works exactly as intended when the message comes from the payment widget. It also works exactly the same way when the message comes from any other page the user has open, or from a malicious iframe embedded anywhere on the same page, because the listener never checks who sent it.

postMessage has no sender built in unless you ask for one

postMessage is designed to let frames from different origins communicate safely, which is a real problem it solves well, browsers otherwise block cross-origin frames from touching each other's DOM or JavaScript state entirely. But "safely" only applies to the parts of the API you actually use. The MessageEvent object your listener receives carries an origin property naming exactly where the message came from, and that property is optional to check. Nothing in the API forces you to look at it, and a listener that just reads event.data works fine in every manual test, since in a manual test, the message really is coming from where you expect.

The gap only shows up when something else on the page, another iframe, a malicious ad, an XSS payload running in a sibling tab, sends a message event of its own. If your listener trusts the shape of event.data alone, it can't distinguish a legitimate checkout-complete message from your payment provider from an identical-looking message crafted by anything else running in the browser. In the example above, that means an attacker can mark an unpaid order as paid with a single call to window.postMessage({ type: 'checkout-complete', orderId: 4821 }, '*') from any frame that shares the page, no server compromise required.

Check the origin before you trust the payload

const TRUSTED_ORIGIN = 'https://checkout.payment-provider.com'

window.addEventListener('message', (event) => {
  if (event.origin !== TRUSTED_ORIGIN) return

  if (event.data.type === 'checkout-complete') {
    updateOrderStatus(event.data.orderId, 'paid')
  }
})

The check has to be an exact match, or a match against a small explicit allowlist if you legitimately expect messages from more than one origin. A substring check like event.origin.includes('payment-provider.com') is a different bug wearing the same clothes, since evil-payment-provider.com.attacker.net passes it too. And critically, the resulting action still shouldn't be treated as authoritative on its own: an order should move to "paid" because your server verified the payment with the provider directly (a webhook, a server-side confirmation call), not because a client-side message said so. postMessage is a fine trigger to refresh the UI or poll your own backend; it's a poor source of truth for anything that changes account state.

The same discipline applies on the sending side. If your own code calls targetWindow.postMessage(data, '*'), the * means "deliver this to whatever origin the target frame currently has," which is fine for non-sensitive UI events and not fine for anything containing a token, an order ID tied to a real transaction, or user data. Specifying the exact expected origin instead of * means the browser itself refuses delivery if the target frame has navigated somewhere unexpected.

Why the unchecked version is the one that gets written

Every postMessage example in a "how do I communicate between an iframe and its parent" search result demonstrates the mechanism, listen for message, read event.data, and most of them are demo code where checking the origin adds a line without changing the observable behavior, since in the demo, only one origin ever sends a message. The check matters exactly when it's least visible in testing: when something adversarial is on the page that a manual click-through will never produce. That's a hard thing for either a human or an assistant to reproduce in a quick test, which is part of why it's such a common gap.

Seaworthy flags message event listeners that read event.data without an event.origin check nearby, particularly where the handler drives an authentication, payment, or account-state change.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode