postMessage Security
Why postMessage Exists
Browsers enforce the same-origin policy — scripts on one origin cannot read data from another origin. That is the single most important security boundary in the browser. But legitimate use cases need cross-origin communication all the time: payment widgets embedded via iframes, OAuth login popups, embedded code editors, analytics dashboards inside admin panels.
window.postMessage is the browser's official escape hatch. It lets two windows (or a window and an iframe) exchange data across origins, with explicit consent from both sides. The key word there is "explicit." When used correctly, postMessage is safe. When used carelessly, it is a front door left wide open.
Think of postMessage like passing a sealed envelope between two apartments in a building. The sender writes the recipient's apartment number on the envelope (that is targetOrigin). The recipient checks who slid the envelope under the door before opening it (that is event.origin validation). If the sender addresses it to "whoever lives here" and the recipient opens every envelope without checking — anyone in the building can read your mail or slip you a fake one.
The postMessage API
The API surface is small. That is part of why people underestimate it.
Sending a Message
// Sender: parent page talking to an iframe
const iframe = document.getElementById("payment-widget");
iframe.contentWindow.postMessage(
{ action: "charge", amount: 4999, currency: "USD" },
"https://payments.example.com" // targetOrigin
);
The first argument is the data. The second argument — targetOrigin — is the origin you intend to receive this message. The browser will only deliver the message if the target window's current origin matches.
Receiving a Message
// Receiver: inside the payment iframe
window.addEventListener("message", (event) => {
// CRITICAL: validate the sender's origin
if (event.origin !== "https://shop.example.com") {
return; // ignore messages from unknown origins
}
const { action, amount, currency } = event.data;
if (action === "charge") {
processPayment(amount, currency);
}
});
Three properties matter on the MessageEvent:
event.data— the payload (structured-cloned, not serialized as JSON)event.origin— the origin of the sender windowevent.source— a reference to the sender window (useful for sending replies)
The targetOrigin Trap
Here is the single most common postMessage vulnerability: using '*' as the targetOrigin.
// DANGEROUS — sends data to whatever origin the iframe happens to be on
iframe.contentWindow.postMessage(sensitiveData, "*");
When you pass '*', the browser delivers the message regardless of the receiver's current origin. If an attacker somehow navigates that iframe to their own page (via an open redirect, a compromised CDN, or a man-in-the-middle on HTTP), they receive your sensitive data.
When Is '*' Acceptable?
Only when the data is truly public and you do not care who reads it. For example, broadcasting a theme change to all embedded widgets where the theme preference is not sensitive. The moment tokens, user data, payment info, or session state are involved — always specify the exact origin.
// Safe: theme preference is not sensitive
iframe.contentWindow.postMessage({ theme: "dark" }, "*");
// DANGEROUS: auth token sent to whoever is listening
iframe.contentWindow.postMessage({ token: userToken }, "*");
Even "non-sensitive" data can become an attack vector. If you broadcast user preferences with '*', an attacker iframe can learn which user is viewing the page based on their preferences, enabling fingerprinting. When in doubt, always specify the exact origin. The cost is one string — the risk of '*' is unbounded.
Origin Validation on the Receiver
The receiver side is equally critical and more commonly broken. Every message event handler must validate event.origin before processing the data.
The Wrong Way
// VULNERABLE: no origin check — any window can trigger this
window.addEventListener("message", (event) => {
document.getElementById("output").innerHTML = event.data;
});
This is a textbook DOM-based XSS. Any page that opens yours in an iframe or popup can send arbitrary HTML, and you inject it into the DOM.
The Right Way
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
window.addEventListener("message", (event) => {
if (!ALLOWED_ORIGINS.has(event.origin)) return;
// Even after origin validation, never trust the shape of event.data
if (typeof event.data !== "object" || event.data === null) return;
if (typeof event.data.action !== "string") return;
handleAction(event.data);
});
Common Origin Check Mistakes
// BUG: substring match — attacker uses "https://evil-app.example.com"
if (event.origin.includes("app.example.com")) { ... }
// BUG: startsWith — attacker uses "https://app.example.com.evil.com"
if (event.origin.startsWith("https://app.example.com")) { ... }
// BUG: regex without anchors — "https://app.example.com.evil.com" matches
if (/app\.example\.com/.test(event.origin)) { ... }
// CORRECT: exact match
if (event.origin === "https://app.example.com") { ... }
// CORRECT: Set lookup for multiple allowed origins
if (ALLOWED_ORIGINS.has(event.origin)) { ... }
Structured Clone, Not JSON
postMessage does not serialize data as JSON. It uses the structured clone algorithm, which is both more powerful and more restrictive than JSON.
What Structured Clone Handles
- Primitives, arrays, plain objects,
Date,RegExp,Map,Set,ArrayBuffer,Blob,File,ImageData,ImageBitmap - Nested structures and circular references
What Structured Clone Rejects
- Functions (you cannot send callbacks across origins)
- DOM nodes (cannot clone live elements)
- Class instances lose their prototype (they arrive as plain objects)
Symbolvalues- Property descriptors, getters, setters
// This works — structured clone handles Map and Date
iframe.contentWindow.postMessage({
users: new Map([["alice", { joinedAt: new Date() }]]),
buffer: new ArrayBuffer(8),
}, "https://target.example.com");
// This throws — functions cannot be cloned
iframe.contentWindow.postMessage({
callback: () => console.log("nope"),
}, "https://target.example.com");
// DOMException: Failed to execute 'postMessage': () => console.log("nope") could not be cloned.
Transferable objects for performance
For large binary data like ArrayBuffer, MessagePort, or OffscreenCanvas, you can transfer ownership instead of copying. The third argument to postMessage is a transfer list:
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
iframe.contentWindow.postMessage(
{ data: buffer },
"https://target.example.com",
[buffer] // transfer list — buffer is moved, not copied
);
// buffer.byteLength is now 0 — ownership was transferredAfter transfer, the original buffer is neutered (zeroed out). This avoids the cost of copying large buffers and is critical for performance-sensitive use cases like WebGL texture data or audio processing buffers shared with Web Workers.
Attack Vectors You Need to Know
1. Missing Origin Check (Most Common)
The receiver listens for messages but never validates event.origin. Any page that embeds or opens the vulnerable page can send arbitrary data.
Impact: DOM XSS (if data is inserted into the DOM), unauthorized actions (if messages trigger state changes), data exfiltration (if the handler sends data back via event.source.postMessage).
2. Insufficient Origin Validation
Using includes(), startsWith(), endsWith(), or unanchored regex instead of exact match. Attackers register lookalike domains that pass the check.
3. Trusting iframe Content Blindly
A parent page sends sensitive data to an iframe assuming the iframe is trusted. But iframes can be navigated — by the iframe itself, by other frames, or by user clicks on attacker links.
// The parent assumes the iframe is still on payments.example.com
// But the iframe navigated itself to attacker.com
iframe.contentWindow.postMessage({ token }, "*");
// attacker.com receives the token
4. Leaking Data via event.source
A vulnerable handler replies to the sender without validating the origin:
// VULNERABLE: sends user data back to whoever asked
window.addEventListener("message", (event) => {
if (event.data.type === "getUser") {
event.source.postMessage(
{ user: currentUser },
"*" // sends to any origin
);
}
});
An attacker opens the vulnerable page in an iframe, sends a getUser message, and receives the current user's data in their own message handler.
5. Prototype Pollution via Message Data
If the handler uses deep merge or recursive object assignment on event.data without sanitization, an attacker can send { "__proto__": { "isAdmin": true } } and pollute the prototype chain.
// VULNERABLE: deep merge without prototype check
window.addEventListener("message", (event) => {
deepMerge(config, event.data); // if event.data has __proto__, trouble
});
Sandboxed Iframes
The sandbox attribute on iframes restricts what the embedded content can do. It is your second layer of defense when embedding third-party content.
<iframe
src="https://widget.example.com"
sandbox="allow-scripts allow-forms"
></iframe>
Sandbox Flags
| Flag | Allows |
|---|---|
allow-scripts | JavaScript execution |
allow-forms | Form submission |
allow-same-origin | Maintaining the iframe's real origin |
allow-popups | Opening new windows |
allow-top-navigation | Navigating the parent page |
allow-modals | alert(), confirm(), prompt() |
The allow-same-origin Danger
Without allow-same-origin, the sandboxed iframe is treated as a unique, opaque origin. It cannot access cookies, localStorage, or anything tied to its real origin. That sounds safe, but combining allow-scripts and allow-same-origin lets the iframe remove its own sandbox:
// Inside a sandboxed iframe with allow-scripts + allow-same-origin
// The iframe can access its own DOM and remove the sandbox attribute
const frame = window.frameElement;
frame.removeAttribute("sandbox"); // sandbox bypassed
Never combine allow-scripts and allow-same-origin on untrusted content. With both flags, the iframe has full script execution AND full access to its origin's storage and APIs — it can remove its own sandbox attribute and break out entirely. Use allow-scripts without allow-same-origin for untrusted widgets, which gives them script execution in an opaque origin with no access to real cookies or storage.
postMessage in Sandboxed Iframes
A sandboxed iframe without allow-same-origin has an opaque origin ("null"). This affects postMessage:
// Parent sending to a sandboxed iframe (no allow-same-origin)
iframe.contentWindow.postMessage(data, "*");
// You MUST use "*" because the iframe's origin is "null"
// There is no meaningful targetOrigin to specify
// Inside the sandboxed iframe, event.origin is "null"
// The parent cannot reliably validate the sender
This is a fundamental tension: sandboxing an iframe for security forces you to use '*' for postMessage, which weakens message-level security. The mitigation is to never send sensitive data to sandboxed iframes and to validate message content rather than relying solely on origin.
Real-World Patterns
Pattern 1: Payment Widget
// Parent page: e-commerce checkout
const paymentFrame = document.getElementById("payment-iframe");
const PAYMENT_ORIGIN = "https://pay.stripe.example.com";
function initiatePayment(amount, currency) {
paymentFrame.contentWindow.postMessage(
{ type: "INIT_PAYMENT", amount, currency },
PAYMENT_ORIGIN
);
}
window.addEventListener("message", (event) => {
if (event.origin !== PAYMENT_ORIGIN) return;
if (event.source !== paymentFrame.contentWindow) return;
switch (event.data.type) {
case "PAYMENT_SUCCESS":
completeOrder(event.data.transactionId);
break;
case "PAYMENT_FAILED":
showError(event.data.reason);
break;
}
});
Notice the double validation: both event.origin and event.source. Checking event.source ensures the message came from the specific iframe you expect, not another window on the same origin.
Pattern 2: OAuth Popup
const AUTH_ORIGIN = "https://auth.example.com";
function openOAuthPopup() {
const popup = window.open(
`${AUTH_ORIGIN}/authorize?redirect_uri=${encodeURIComponent(window.location.origin + "/oauth-callback")}`,
"oauth",
"width=500,height=600"
);
function handleAuth(event) {
if (event.origin !== AUTH_ORIGIN) return;
if (event.source !== popup) return;
window.removeEventListener("message", handleAuth);
popup.close();
if (event.data.type === "AUTH_SUCCESS") {
exchangeCodeForToken(event.data.code);
}
}
window.addEventListener("message", handleAuth);
}
Key details: the handler is a named function so it can remove itself after receiving the auth response. event.source is checked against the specific popup reference. The handler is cleaned up to avoid lingering listeners.
Pattern 3: Embedded Editor with Message Protocol
const EDITOR_ORIGIN = "https://editor.example.com";
const pendingRequests = new Map();
let requestId = 0;
function sendEditorCommand(command, payload) {
return new Promise((resolve, reject) => {
const id = ++requestId;
pendingRequests.set(id, { resolve, reject });
editorFrame.contentWindow.postMessage(
{ id, command, payload },
EDITOR_ORIGIN
);
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error("Editor command timed out"));
}
}, 5000);
});
}
window.addEventListener("message", (event) => {
if (event.origin !== EDITOR_ORIGIN) return;
if (!event.data.id || !pendingRequests.has(event.data.id)) return;
const { resolve, reject } = pendingRequests.get(event.data.id);
pendingRequests.delete(event.data.id);
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data.result);
}
});
// Usage
const code = await sendEditorCommand("getContent", { format: "html" });
This pattern builds a request-response protocol on top of postMessage using unique IDs. The timeout prevents memory leaks from unresolved promises if the iframe never responds.
Security Checklist for postMessage
- 1Always specify the exact targetOrigin when sending — never use '*' for sensitive data
- 2Always validate event.origin with exact string equality (===) or a Set — never use includes(), startsWith(), or unanchored regex
- 3Check event.source against the expected window reference to prevent spoofing from same-origin windows
- 4Never insert event.data into the DOM without sanitization — treat it as untrusted input even after origin validation
- 5Never combine allow-scripts and allow-same-origin on sandboxed iframes with untrusted content
- 6Clean up message event listeners when they are no longer needed — lingering handlers are an attack surface
- 7Validate the shape and types of event.data before processing — origins can be trusted, payloads cannot
| What developers do | What they should do |
|---|---|
| Using targetOrigin '*' because the exact origin is not known at build time Using '*' means any origin receives the message — if the iframe navigates, an attacker gets your data | Store the target origin in a config variable or derive it from the iframe's src attribute at runtime |
| Checking event.origin with includes() or startsWith() Substring checks match attacker domains like evil-app.example.com or app.example.com.evil.com | Use exact string comparison with === or a Set of allowed origins |
| Using innerHTML to render postMessage data without sanitization Attacker-controlled message data inserted as HTML is a DOM XSS vulnerability | Use textContent for text, or sanitize with DOMPurify before inserting HTML |
| Not removing message event listeners after use (especially in OAuth or popup flows) Orphaned listeners continue processing messages long after the flow completes, expanding the attack surface | Use named handler functions and removeEventListener after the expected message arrives |
| Adding allow-same-origin to sandboxed iframes that also have allow-scripts With both flags, the iframe can remove its own sandbox attribute and escape all restrictions | Use allow-scripts alone for untrusted content — the iframe runs in an opaque origin |