Modern web apps routinely embed a payment step, an identity provider, an editor, or a support widget from another origin. The browser's same-origin policy prevents those documents from directly reading each other's DOM, but window.postMessage() gives them a deliberate communication channel.
That channel is useful precisely because it crosses an origin boundary. It should therefore be designed like an API boundary, not like a convenient callback. A message listener that trusts any sender, accepts any object, and performs work immediately turns a small integration into an input-validation and authorization surface.
This article builds a small request protocol for a parent page and an embedded frame. It is not a DRM, access-control, or authentication bypass technique; it is a defensive pattern for applications that already control both ends of a legitimate integration.
The three questions every message must answer
When a message event arrives, answer these questions before acting:
-
Who sent it? Check
event.originagainst an exact allowlist. -
Which window sent it? When the relationship is known, check
event.sourceagainst the expected iframe window. - Is this a message we understand? Validate its shape, type, and values before using them.
The HTML Standard says authors should check both the origin and the expected data format. It also warns against using "*" as targetOrigin for confidential information. Those are separate checks: an approved origin does not make arbitrary JSON safe, and a well-formed payload does not make an unknown sender trustworthy.
Start with a narrow protocol
Suppose the parent embeds an account-widget frame at https://widget.example. The parent wants to tell the frame which non-secret display theme to use. The frame later reports that it is ready.
<iframe
id="account-widget"
src="https://widget.example/embed"
title="Account settings"
></iframe>
Do not send configuration as soon as the iframe element exists. Navigation and listener setup are asynchronous. The HTML Standard specifically recommends a readiness message from a newly created child document before the parent begins posting messages.
const WIDGET_ORIGIN = "https://widget.example";
const frame = document.querySelector("#account-widget");
function isWidgetMessage(event) {
return event.origin === WIDGET_ORIGIN &&
event.source === frame.contentWindow &&
typeof event.data === "object" &&
event.data !== null &&
!Array.isArray(event.data) &&
typeof event.data.type === "string";
}
window.addEventListener("message", (event) => {
if (!isWidgetMessage(event)) return;
if (event.data.type === "widget.ready") {
frame.contentWindow.postMessage(
{ type: "widget.configure", theme: "dark" },
WIDGET_ORIGIN
);
}
});
The exact target origin matters. With WIDGET_ORIGIN, the browser discards the message if the target window is no longer at that origin. With "*", it would deliver regardless of origin. The latter may be necessary for an opaque origin such as a data: URL, but that is an architectural exception to document and isolate—not a default for ordinary hosted frames.
Validate the payload as data, not as intention
The helper above only proves that type is a string. Production code needs a validator for each command. The validator should create a plain, minimal internal value rather than passing the received object through the application.
function parseWidgetEvent(data) {
if (typeof data !== "object" || data === null || Array.isArray(data)) {
return null;
}
switch (data.type) {
case "widget.ready":
return { type: "widget.ready" };
case "widget.resize":
if (!Number.isInteger(data.height)) return null;
if (data.height < 120 || data.height > 1200) return null;
return { type: "widget.resize", height: data.height };
default:
return null;
}
}
window.addEventListener("message", (event) => {
if (event.origin !== WIDGET_ORIGIN) return;
if (event.source !== frame.contentWindow) return;
const message = parseWidgetEvent(event.data);
if (!message) return;
if (message.type === "widget.resize") {
frame.style.height = `${message.height}px`;
}
});
This has useful properties: unknown commands fail closed, range checks are explicit, and the application never interprets a received string as HTML or JavaScript. Avoid patterns such as Object.assign(state, event.data), dynamic property dispatch, or feeding received values into innerHTML. They blur the boundary that the protocol is meant to enforce.
Schema libraries can make larger protocols more maintainable, but they do not replace origin and source checks. Keep the protocol small enough that its accepted messages can be reviewed as a list.
Treat replies and lifecycle events carefully
A common mistake is to reply with event.source.postMessage(reply, "*"). The standard's own example replies to event.origin, which preserves the origin constraint that was observed for that event.
function reply(event, message) {
event.source?.postMessage(message, event.origin);
}
There is still a lifecycle edge case: a WindowProxy can survive a navigation while the document inside it changes. On every incoming event, check event.origin again; do not treat an earlier handshake as permanent authorization. For actions with security consequences, require a fresh, explicit request and enforce authorization on the server or other trusted authority. A browser message is a transport signal, not proof of user intent or permission.
Also bound the work triggered by messages. The HTML Standard notes that a page accepting messages from any origin can be exposed to denial-of-service if each message causes expensive computation or network traffic. Even when you use an allowlist, coalesce resize events, cap payload sizes, and rate-limit operations that can initiate work.
Transferables are about ownership
postMessage uses structured cloning. Some values, including ArrayBuffer, can be transferred instead of cloned:
const bytes = new Uint8Array([1, 2, 3]);
frame.contentWindow.postMessage(
{ type: "widget.bytes", bytes: bytes.buffer },
WIDGET_ORIGIN,
[bytes.buffer]
);
// bytes.buffer is now detached in this window.
Transfer can avoid copying large data, but it changes ownership: the sender can no longer use the transferred object. Only transfer buffers you are prepared to relinquish, and keep message size limits so an integration cannot turn memory pressure into a reliability problem. For simple UI coordination, structured, small data is usually the clearer choice.
A review checklist
Before shipping a cross-window integration, verify:
- Every send uses an exact
targetOriginunless an opaque-origin exception is documented. - Every receive checks exact
event.origin. - Known relationships also verify
event.source. - Every command has a small, explicit parser with type and range checks.
- Unknown messages do nothing; received content is never executed or injected as HTML.
- Startup uses a readiness handshake rather than timing assumptions.
- High-frequency or expensive commands are bounded.
- Authorization for sensitive actions is enforced outside the message channel.
The key idea is simple: postMessage is not just a browser event. It is a cross-origin protocol. Give it the same allowlists, schemas, lifecycle rules, and failure modes you would require of any other public API.
Top comments (0)