Fundamentos de postMessage — Comunicación segura entre ventanas e iframes de diferentes orígenes
Receta
// Padre envía un mensaje a un iframe
const iframeRef = useRef<HTMLIFrameElement>(null);
function sendToIframe(data: unknown) {
iframeRef.current?.contentWindow?.postMessage(
data,
"https://widget.example.com" // SIEMPRE especifica el origen destino
);
}
// Iframe (o cualquier ventana) escucha mensajes
useEffect(() => {
function handleMessage(event: MessageEvent) {
// CRÍTICO: siempre valida el origen
if (event.origin !== "https://parent.example.com") return;
console.log("Received:", event.data);
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);Cuándo usarlo: Cuando necesitas comunicación entre una página padre e un iframe incrustado, entre ventanas abiertas con window.open, o entre dos contextos de navegación que no pueden compartir directamente el scope de JavaScript. Los casos de uso comunes incluyen micro-frontends, widgets de terceros incrustados, formularios de pago, popups de OAuth, y flujos de autenticación entre orígenes.
Ejemplo Funcional
Una página padre envía un objeto de tema a un iframe incrustado. El iframe aplica el tema y envía un reconocimiento.
Aplicación Padre
import { useEffect, useRef, useState } from "react";
// -- Protocolo de mensajes tipado usando uniones discriminadas --
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);
// Escucha respuestas del 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>Aplicación Padre</h1>
<button onClick={() => sendTheme("light")}>Tema Claro</button>
<button onClick={() => sendTheme("dark")}>Tema Oscuro</button>
{acked && <p>Widget reconoció el tema</p>}
<iframe
ref={iframeRef}
src={`${IFRAME_ORIGIN}/widget`}
title="Widget Incrustado"
style={{ width: 600, height: 400, border: "1px solid #ccc" }}
/>
</div>
);
}Widget del Iframe
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>) {
// CRÍTICO: valida el origen antes de procesar
if (event.origin !== PARENT_ORIGIN) return;
switch (event.data.type) {
case "THEME_UPDATE":
setTheme(event.data.payload);
// Envía reconocimiento de vuelta al padre
const ack: IframeMessage = {
type: "THEME_ACK",
payload: { appliedAt: Date.now() },
};
// event.source es la ventana que envió el mensaje
(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 }}>Contenido del Widget</h2>
<p>Tema actual: {theme.mode}</p>
</div>
);
}Análisis Profundo
¿Qué es postMessage?
window.postMessage()es la API estándar del navegador para comunicación segura entre orígenes entre contextos de navegación (ventanas, iframes, popups, workers).- Sin postMessage, la política de mismo origen bloquea JavaScript en un origen de acceder al DOM o variables de otro origen. postMessage proporciona un canal controlado para enviar datos serializables a través de ese límite.
- La firma del método es
targetWindow.postMessage(message, targetOrigin, transfer?).
Anatomía de MessageEvent
Cada manejador de mensajes recibe un MessageEvent con estas propiedades clave:
| Propiedad | Tipo | Descripción |
|---|---|---|
data | any | El payload del mensaje (copia estructurada de lo que se envió) |
origin | string | El origen de la ventana remitente (ej., https://example.com) |
source | Window o MessagePort o ServiceWorker o null | Referencia a la ventana remitente; úsala para responder |
ports | ReadonlyArray<MessagePort> | Puertos de MessageChannel transferidos con el mensaje |
lastEventId | string | Cadena vacía para postMessage (usada por SSE) |
Cómo Funciona
- El remitente llama a
postMessage(data, targetOrigin)en el objeto ventana destino (no en la suya propia). - El navegador comprueba si el origen actual de la ventana destino coincide con
targetOrigin. Si no coincide, el mensaje se descarta silenciosamente. - El payload del mensaje se serializa usando el algoritmo de clonación estructurada (no JSON). Esto admite
Date,Map,Set,ArrayBuffer,Blob,File,RegExp, arrays tipados y objetos anidados. No admite funciones, nodos DOM,Symbol, oWeakMap/WeakSet. - El mensaje se distribuye de forma asíncrona (pasa a través del event loop). Nunca es síncrono.
- El listener de evento
"message"de la ventana receptora se activa con unMessageEventque contiene los datos clonados, el origen del remitente, y una referencia a la ventana del remitente.
La Validación de Origen es Innegociable
- Siempre comprueba
event.originen cada manejador de mensajes. Sin esta comprobación, cualquier página que pueda hacer iframe de tu página (o de la que tu página haga iframe) puede enviar mensajes arbitrarios a tu manejador. - Siempre especifica
targetOrigincuando llames apostMessage. Usar"*"significa que cualquier origen puede recibir el mensaje. Esto es peligroso cuando el mensaje contiene datos sensibles (tokens, información del usuario, etc.). Consulta el documento de seguridad para detalles completos. - Usa una allowlist de orígenes conocidos, no un patrón de coincidencia de regex (los regex son fáciles de cometer errores y pueden eludirse).
Patrón de Integración con React
// Patrón useEffect limpio para listeners de postMessage
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;
// Acotar el tipo basado en unión discriminada
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);
}, []); // Deps vacías: listener es estable, no se necesitan closures obsoletosUniones Discriminadas de TypeScript para Protocolos de Mensajes
// Define todos los mensajes en una unión. El campo "type" es el discriminante.
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 se necesita payload
// Type guard para validar datos entrantes desconocidos
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);
}
// Uso en manejador
function handleMessage(event: MessageEvent) {
if (event.origin !== EXPECTED_ORIGIN) return;
if (!isAppMessage(event.data)) return;
// TypeScript ahora se estrecha correctamente en cada caso
switch (event.data.type) {
case "AUTH_TOKEN":
// event.data.payload es { token: string; expiresAt: number }
setToken(event.data.payload.token);
break;
case "NAVIGATE":
// event.data.payload es { path: string }
router.push(event.data.payload.path);
break;
}
}Gotchas
- Olvidar comprobaciones de origen es la vulnerabilidad de seguridad de postMessage número uno. Nunca saltes la validación de
event.origin, ni siquiera en desarrollo. Construye el hábito temprano. "*"como origen destino se emite a cualquier origen. Úsalo solo para mensajes verdaderamente públicos y no sensibles (y ni siquiera entonces, piénsalo dos veces).- Los mensajes son async.
postMessageno devuelve un valor. Si necesitas una respuesta, debes implementar un protocolo request/response con IDs de mensaje (cubierto en la guía intermedia). event.sourcepuede ser null si la ventana remitente ha sido cerrada antes de que tu manejador se active. Siempre null-check antes de responder.- El iframe debe estar cargado antes de que puedas enviarle mensajes. Enviar a
contentWindowantes de que se active el eventoloaddel iframe fallará silenciosamente. Usa el callbackonLoaddel iframe o espera un mensajeREADYdel iframe. - Múltiples listeners pueden entrar en conflicto. Si añades un listener de mensaje en múltiples componentes, cada listener se activará para cada mensaje. Usa el campo
typedel mensaje para enrutar al manejador correcto. - La clonación estructurada no es JSON. La clonación estructurada preserva objetos
Date,Map,Set, etc., pero lanzará una excepción con funciones y nodos DOM. Si necesitas verificar qué es cloneable, usastructuredClone()localmente para probar. - React StrictMode dispara efectos doblemente en desarrollo, lo que significa que tu listener se agregará dos veces (luego se limpie y se vuelva a agregar). Esto está bien porque la función de limpieza elimina el anterior, pero ten cuidado cuando depures.
Alternativas
| Enfoque | Cuándo Usar |
|---|---|
| BroadcastChannel | Pestañas o ventanas del mismo origen que necesitan compartir estado (sin soporte para cross-origin) |
| Channel Messaging (MessageChannel) | Puerto bidireccional dedicado entre exactamente dos contextos (cubierto en la guía avanzada) |
| SharedWorker | Múltiples pestañas del mismo origen que comparten un único worker para coordinación de estado |
| Custom Events | Comunicación dentro del mismo documento (sin cross-origin, sin cross-frame) |
| Server-Sent Events o WebSocket | Cuando necesitas comunicación mediada por servidor entre clientes |
| URL fragment o query params | Paso de datos único a un iframe (sin comunicación continua) |
Preguntas Frecuentes
¿Cuál es el primer argumento de window.postMessage y qué tipos de datos puede llevar?
- El primer argumento es el payload del mensaje, que se serializa a través del algoritmo de clonación estructurada.
- Los tipos admitidos incluyen primitivos, objetos simples, arrays,
Date,Map,Set,ArrayBuffer,Blob,File, yRegExp. - Las funciones, nodos DOM,
Symbol,WeakMap, yWeakSetno se admiten y lanzarán unDataCloneError.
¿Por qué debes siempre comprobar event.origin en cada manejador de mensajes?
- Sin una comprobación de origen, cualquier página que pueda hacer iframe de tu página (o de la que tu página haga iframe) puede enviar mensajes arbitrarios a tu manejador.
- Esta es la vulnerabilidad de seguridad de postMessage número uno.
- Usa un
Setde orígenes conocidos y buenos para una validación rápida y de coincidencia exacta.
¿Qué sucede si usas "*" como targetOrigin?
- El navegador entrega el mensaje a la ventana destino independientemente de su origen actual.
- Si el iframe ha navegado a otro lado, tu mensaje (potencialmente conteniendo tokens o datos de usuario) va a cualquier página que ahora esté cargada.
- Solo usa
"*"para mensajes verdaderamente públicos y no sensibles.
¿Cómo escribes un manejador de postMessage en TypeScript usando uniones discriminadas?
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 es { path: string }
break;
}
}¿Cómo creas un type guard para datos desconocidos de postMessage entrantes en 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: ¿Qué sucede si envías un postMessage a un iframe antes de que haya cargado?
- El mensaje se descarta silenciosamente porque
contentWindowno está listo. - Usa el callback
onLoaddel iframe o espera un mensajeREADYdel iframe antes de enviar.
¿Es postMessage síncrono o asíncrono?
- Siempre es asíncrono. El mensaje pasa a través del event loop.
postMessageno devuelve un valor. Si necesitas una respuesta, debes construir un protocolo request/response con IDs de correlación.
¿Por qué debes usar un Set de allowlist en lugar de un regex para la validación de origen?
- Los regex son fáciles de cometer errores (ej., puntos sin escapar, anclajes faltantes) y pueden eludirse.
- Una búsqueda en
Setes una coincidencia de cadena exacta, que es tanto más segura como más rápida.
¿Cómo afecta React StrictMode a los listeners de postMessage?
- StrictMode dispara efectos doblemente en desarrollo, por lo que tu listener se agrega dos veces.
- La función de limpieza elimina el listener anterior, por lo que funciona correctamente.
- Ten cuidado de esto cuando depures registros de mensajes duplicados en desarrollo.
¿Cuál es la diferencia entre clonación estructurada y serialización JSON para postMessage?
- La clonación estructurada preserva
Date,Map,Set,ArrayBuffer,Blob, referencias circulares, y más. - JSON pierde estos tipos (ej.,
Datese convierte en una cadena,Mapse convierte en{}). - No necesitas hacer
JSON.stringifyantes de enviar oJSON.parseal recibir.
Gotcha: ¿Puede event.source ser null, y cuándo ocurre eso?
- Sí. Si la ventana remitente ha sido cerrada antes de que tu manejador se active,
event.sourceesnull. - Siempre haz null-check de
event.sourceantes de llamar apostMessageen él para responder.
¿Cuál es el patrón correcto de limpieza de useEffect para listeners de postMessage en React?
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== EXPECTED) return;
// handle message
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);Relacionado
- Patrones Intermedios de postMessage — patrón request/response, hook de bus de mensajes, y comunicación bidireccional
- Patrones Avanzados de postMessage — MessageChannel, transferibles, capa RPC, arquitectura de micro-frontend
- Seguridad de postMessage — validación de origen, CSP, sandboxing, y embebimiento seguro de widgets
Videos Relacionados
How to Send Data From iframe To Parent Page — JavaScript postMessage Tutorial
Window postMessage() protocol using React - #1 JavaScript Tutorials