postMessage Fundamentals — Secure cross-origin communication between windows and iframes
Recipe
// Parent sends a message to an iframe
const iframeRef = useRef<HTMLIFrameElement>(null);
function sendToIframe(data: unknown) {
iframeRef.current?.contentWindow?.postMessage(
data,
"https://widget.example.com" // ALWAYS specify the target origin
);
}
// Iframe (or any window) listens for messages
useEffect(() => {
function handleMessage(event: MessageEvent) {
// CRITICAL: always validate the origin
if (event.origin !== "https://parent.example.com") return;
console.log("Received:", event.data);
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);When to reach for this: When you need communication between a parent page and an embedded iframe, between windows opened with window.open, or between any two browsing contexts that cannot share JavaScript scope directly. Common use cases include micro-frontends, embedded third-party widgets, payment forms, OAuth popups, and cross-origin auth flows.
Working Example
A parent page sends a theme object to an embedded iframe. The iframe applies the theme and sends back an acknowledgement.
Parent App
import { useEffect, useRef, useState } from "react";
// -- Typed message protocol using discriminated unions --
type ParentMessage =
| { type: "THEME_UPDATE"; payload: Theme }
| { type: "PING" };
type IframeMessage =
| { type: "THEME_ACK"; payload: { appliedAt: number } }
| { type: "PONG" };
interface Theme {
mode: "light" | "dark";
primaryColor: string;
fontSize: number;
}
const IFRAME_ORIGIN = "https://widget.example.com";
function ParentApp() {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [acked, setAcked] = useState(false);
// Listen for responses from the iframe
useEffect(() => {
function handleMessage(event: MessageEvent<IframeMessage>) {
if (event.origin !== IFRAME_ORIGIN) return;
switch (event.data.type) {
case "THEME_ACK":
console.log("Theme applied at:", event.data.payload.appliedAt);
setAcked(true);
break;
case "PONG":
console.log("Iframe is alive");
break;
}
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
function sendTheme(mode: "light" | "dark") {
const message: ParentMessage = {
type: "THEME_UPDATE",
payload: { mode, primaryColor: "#3b82f6", fontSize: 16 },
};
iframeRef.current?.contentWindow?.postMessage(message, IFRAME_ORIGIN);
setAcked(false);
}
return (
<div>
<h1>Parent App</h1>
<button onClick={() => sendTheme("light")}>Light Theme</button>
<button onClick={() => sendTheme("dark")}>Dark Theme</button>
{acked && <p>Theme acknowledged by widget</p>}
<iframe
ref={iframeRef}
src={`${IFRAME_ORIGIN}/widget`}
title="Embedded Widget"
style={{ width: 600, height: 400, border: "1px solid #ccc" }}
/>
</div>
);
}Iframe Widget
import { useEffect, useState } from "react";
interface Theme {
mode: "light" | "dark";
primaryColor: string;
fontSize: number;
}
type ParentMessage =
| { type: "THEME_UPDATE"; payload: Theme }
| { type: "PING" };
type IframeMessage =
| { type: "THEME_ACK"; payload: { appliedAt: number } }
| { type: "PONG" };
const PARENT_ORIGIN = "https://parent.example.com";
function Widget() {
const [theme, setTheme] = useState<Theme>({
mode: "light",
primaryColor: "#3b82f6",
fontSize: 16,
});
useEffect(() => {
function handleMessage(event: MessageEvent<ParentMessage>) {
// CRITICAL: validate origin before processing
if (event.origin !== PARENT_ORIGIN) return;
switch (event.data.type) {
case "THEME_UPDATE":
setTheme(event.data.payload);
// Send acknowledgement back to parent
const ack: IframeMessage = {
type: "THEME_ACK",
payload: { appliedAt: Date.now() },
};
// event.source is the window that sent the message
(event.source as Window).postMessage(ack, event.origin);
break;
case "PING":
(event.source as Window).postMessage(
{ type: "PONG" } satisfies IframeMessage,
event.origin
);
break;
}
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
return (
<div
style={{
backgroundColor: theme.mode === "dark" ? "#1e293b" : "#ffffff",
color: theme.mode === "dark" ? "#f1f5f9" : "#0f172a",
fontSize: theme.fontSize,
padding: 24,
minHeight: "100vh",
}}
>
<h2 style={{ color: theme.primaryColor }}>Widget Content</h2>
<p>Current theme: {theme.mode}</p>
</div>
);
}Deep Dive
What is postMessage?
window.postMessage()is the browser-standard API for safe cross-origin communication between browsing contexts (windows, iframes, popups, workers).- Without postMessage, the same-origin policy blocks JavaScript in one origin from accessing the DOM or variables of another origin. postMessage provides a controlled channel to send serializable data across that boundary.
- The method signature is
targetWindow.postMessage(message, targetOrigin, transfer?).
MessageEvent Anatomy
Every message handler receives a MessageEvent with these key properties:
| Property | Type | Description |
|---|---|---|
data | any | The message payload (structured-clone of what was sent) |
origin | string | The origin of the sending window (e.g., https://example.com) |
source | Window or MessagePort or ServiceWorker or null | Reference to the sender window; use to reply |
ports | ReadonlyArray<MessagePort> | MessageChannel ports transferred with the message |
lastEventId | string | Empty string for postMessage (used by SSE) |
How It Works
- The sender calls
postMessage(data, targetOrigin)on the target window object (not its own). - The browser checks whether the target window's current origin matches
targetOrigin. If it does not match, the message is silently dropped. - The message payload is serialized using the structured clone algorithm (not JSON). This supports
Date,Map,Set,ArrayBuffer,Blob,File,RegExp, typed arrays, and nested objects. It does not support functions, DOM nodes,Symbol, orWeakMap/WeakSet. - The message is dispatched asynchronously (it goes through the event loop). It is never synchronous.
- The receiving window's
"message"event listener fires with aMessageEventcontaining the cloned data, the sender's origin, and a reference to the sender's window.
Origin Validation is Non-Negotiable
- Always check
event.originin every message handler. Without this check, any page that can iframe your page (or that your page iframes) can send arbitrary messages to your handler. - Always specify
targetOriginwhen callingpostMessage. Using"*"means any origin can receive the message. This is dangerous when the message contains sensitive data (tokens, user info, etc.). See the security doc for full details. - Use an allowlist of known origins, not a regex pattern match (regexes are easy to get wrong and can be bypassed).
React Integration Pattern
// Clean useEffect pattern for postMessage listeners
useEffect(() => {
const ALLOWED_ORIGINS = new Set([
"https://widget-a.example.com",
"https://widget-b.example.com",
]);
function handleMessage(event: MessageEvent) {
if (!ALLOWED_ORIGINS.has(event.origin)) return;
// Type-narrow based on discriminated union
const msg = event.data;
if (typeof msg !== "object" || msg === null || !("type" in msg)) return;
switch (msg.type) {
case "THEME_UPDATE":
// handle...
break;
// ...
}
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []); // Empty deps: listener is stable, no stale closures neededTypeScript Discriminated Unions for Message Protocols
// Define all messages in a union. The "type" field is the discriminant.
type AppMessage =
| { type: "NAVIGATE"; payload: { path: string } }
| { type: "AUTH_TOKEN"; payload: { token: string; expiresAt: number } }
| { type: "RESIZE"; payload: { width: number; height: number } }
| { type: "READY" }; // no payload needed
// Type guard to validate unknown incoming data
function isAppMessage(data: unknown): data is AppMessage {
if (typeof data !== "object" || data === null) return false;
if (!("type" in data)) return false;
const d = data as { type: string };
return ["NAVIGATE", "AUTH_TOKEN", "RESIZE", "READY"].includes(d.type);
}
// Usage in handler
function handleMessage(event: MessageEvent) {
if (event.origin !== EXPECTED_ORIGIN) return;
if (!isAppMessage(event.data)) return;
// TypeScript now narrows correctly in each case
switch (event.data.type) {
case "AUTH_TOKEN":
// event.data.payload is { token: string; expiresAt: number }
setToken(event.data.payload.token);
break;
case "NAVIGATE":
// event.data.payload is { path: string }
router.push(event.data.payload.path);
break;
}
}Gotchas
- Forgetting origin checks is the number one postMessage security vulnerability. Never skip
event.originvalidation, even in development. Build the habit early. "*"as target origin broadcasts to any origin. Only use it for truly public, non-sensitive messages (and even then, think twice).- Messages are async.
postMessagedoes not return a value. If you need a response, you must implement a request/response protocol with message IDs (covered in the intermediate guide). event.sourcecan be null if the sending window has been closed before your handler fires. Always null-check before replying.- Iframe must be loaded before you can send messages to it. Sending to
contentWindowbefore the iframe'sloadevent fires will silently fail. Use the iframe'sonLoadcallback or wait for aREADYmessage from the iframe. - Multiple listeners can conflict. If you add a message listener in multiple components, every listener will fire for every message. Use the message
typefield to route to the correct handler. - Structured clone is not JSON. Structured clone preserves
Dateobjects,Map,Set, etc., but it will throw on functions and DOM nodes. If you need to verify what is cloneable, usestructuredClone()locally to test. - React StrictMode double-fires effects in development, which means your listener will be added twice (then cleaned up and re-added). This is fine because the cleanup function removes the old one, but be aware of it when debugging.
Alternatives
| Approach | When to Use |
|---|---|
| BroadcastChannel | Same-origin tabs or windows that need to share state (no cross-origin support) |
| Channel Messaging (MessageChannel) | Dedicated two-way port between exactly two contexts (covered in advanced guide) |
| SharedWorker | Multiple same-origin tabs sharing a single worker for state coordination |
| Custom Events | Communication within the same document (no cross-origin, no cross-frame) |
| Server-Sent Events or WebSocket | When you need server-mediated communication between clients |
| URL fragment or query params | One-time data passing to an iframe (no ongoing communication) |
FAQs
What is the first argument to window.postMessage and what types of data can it carry?
- The first argument is the message payload, which is serialized via the structured clone algorithm.
- Supported types include primitives, plain objects, arrays,
Date,Map,Set,ArrayBuffer,Blob,File, andRegExp. - Functions, DOM nodes,
Symbol,WeakMap, andWeakSetare not supported and will throw aDataCloneError.
Why must you always check event.origin in every message handler?
- Without an origin check, any page that can iframe your page (or that your page iframes) can send arbitrary messages to your handler.
- This is the number one postMessage security vulnerability.
- Use a
Setof known-good origins for fast, exact-match validation.
What happens if you use "*" as the targetOrigin?
- The browser delivers the message to the target window regardless of its current origin.
- If the iframe has navigated away, your message (potentially containing tokens or user data) goes to whatever page is now loaded.
- Only use
"*"for truly public, non-sensitive messages.
How do you type a postMessage handler in TypeScript using discriminated unions?
type AppMessage =
| { type: "NAVIGATE"; payload: { path: string } }
| { type: "READY" };
function handleMessage(event: MessageEvent<AppMessage>) {
if (event.origin !== EXPECTED_ORIGIN) return;
switch (event.data.type) {
case "NAVIGATE":
// event.data.payload is { path: string }
break;
}
}How do you create a type guard for unknown incoming postMessage data in TypeScript?
function isAppMessage(data: unknown): data is AppMessage {
if (typeof data !== "object" || data === null) return false;
if (!("type" in data)) return false;
const d = data as { type: string };
return ["NAVIGATE", "READY"].includes(d.type);
}Gotcha: What happens if you send a postMessage to an iframe before it has loaded?
- The message is silently dropped because
contentWindowis not ready. - Use the iframe's
onLoadcallback or wait for aREADYmessage from the iframe before sending.
Is postMessage synchronous or asynchronous?
- It is always asynchronous. The message goes through the event loop.
postMessagedoes not return a value. If you need a response, you must build a request/response protocol with correlation IDs.
Why should you use an allowlist Set instead of a regex for origin validation?
- Regexes are easy to get wrong (e.g., unescaped dots, missing anchors) and can be bypassed.
- A
Setlookup is an exact string match, which is both safer and faster.
How does React StrictMode affect postMessage listeners?
- StrictMode double-fires effects in development, so your listener is added twice.
- The cleanup function removes the old listener, so it works correctly.
- Be aware of this when debugging duplicate message logs in development.
What is the difference between structured clone and JSON serialization for postMessage?
- Structured clone preserves
Date,Map,Set,ArrayBuffer,Blob, circular references, and more. - JSON loses these types (e.g.,
Datebecomes a string,Mapbecomes{}). - You do not need to
JSON.stringifybefore sending orJSON.parseon receive.
Gotcha: Can event.source be null, and when does that happen?
- Yes. If the sending window has been closed before your handler fires,
event.sourceisnull. - Always null-check
event.sourcebefore callingpostMessageon it to reply.
What is the correct useEffect cleanup pattern for postMessage listeners in React?
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== EXPECTED) return;
// handle message
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);Related
- Intermediate postMessage Patterns — request/response pattern, message bus hook, and bidirectional communication
- Advanced postMessage Patterns — MessageChannel, transferables, RPC layer, micro-frontend architecture
- postMessage Security — origin validation, CSP, sandboxing, and secure widget embedding