postMessage Security — Origin validation, CSP, sandboxing, and secure widget embedding
Recipe
// The three non-negotiable security checks for every message handler:
useEffect(() => {
function handleMessage(event: MessageEvent) {
// 1. VALIDATE ORIGIN — reject messages from unknown senders
if (!ALLOWED_ORIGINS.has(event.origin)) return;
// 2. VALIDATE SHAPE — never trust that data matches your expected type
const result = messageSchema.safeParse(event.data);
if (!result.success) return;
// 3. SANITIZE CONTENT — if the data will be rendered, sanitize it
const message = result.data;
// ... handle validated message
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);When to reach for this: Always. Every postMessage handler in production code must validate origin and data shape. These patterns are not optional hardening; they are baseline requirements. A missing origin check is a direct XSS vector.
Working Example
A secure widget embedding pattern with strict origin validation and typed message validation using Zod.
Shared Schema (used by both parent and widget)
// schemas/widget-messages.ts
import { z } from "zod";
// -- Parent to Widget messages --
export const themeUpdateSchema = z.object({
type: z.literal("THEME_UPDATE"),
payload: z.object({
mode: z.enum(["light", "dark"]),
primaryColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
fontSize: z.number().int().min(10).max(32),
}),
});
export const dataUpdateSchema = z.object({
type: z.literal("DATA_UPDATE"),
payload: z.object({
items: z.array(
z.object({
id: z.string().uuid(),
label: z.string().max(200),
value: z.number().finite(),
})
).max(1000), // Prevent unbounded array attacks
}),
});
export const configUpdateSchema = z.object({
type: z.literal("CONFIG_UPDATE"),
payload: z.object({
locale: z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/),
currency: z.string().length(3),
timezone: z.string().max(50),
}),
});
export const parentMessageSchema = z.discriminatedUnion("type", [
themeUpdateSchema,
dataUpdateSchema,
configUpdateSchema,
]);
export type ParentMessage = z.infer<typeof parentMessageSchema>;
// -- Widget to Parent messages --
export const widgetReadySchema = z.object({
type: z.literal("WIDGET_READY"),
payload: z.object({
version: z.string(),
capabilities: z.array(z.string()),
}),
});
export const widgetEventSchema = z.object({
type: z.literal("WIDGET_EVENT"),
payload: z.object({
action: z.enum(["click", "hover", "select", "dismiss"]),
target: z.string().max(100),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
});
export const widgetErrorSchema = z.object({
type: z.literal("WIDGET_ERROR"),
payload: z.object({
code: z.string().max(50),
message: z.string().max(500),
}),
});
export const widgetMessageSchema = z.discriminatedUnion("type", [
widgetReadySchema,
widgetEventSchema,
widgetErrorSchema,
]);
export type WidgetMessage = z.infer<typeof widgetMessageSchema>;Secure Parent (host page)
import { useEffect, useRef, useState, useCallback } from "react";
import { widgetMessageSchema, type ParentMessage } from "./schemas/widget-messages";
// Strict origin allowlist — no wildcards, no regexes
const WIDGET_ORIGINS = new Set([
"https://widget.example.com",
"https://widget-staging.example.com",
]);
// Content Security Policy for the page (set via meta tag or HTTP header)
// <meta http-equiv="Content-Security-Policy"
// content="frame-src https://widget.example.com https://widget-staging.example.com;
// default-src 'self';">
function SecureWidgetHost() {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [widgetReady, setWidgetReady] = useState(false);
const [widgetVersion, setWidgetVersion] = useState<string | null>(null);
useEffect(() => {
function handleMessage(event: MessageEvent) {
// STEP 1: Origin validation (allowlist, not regex)
if (!WIDGET_ORIGINS.has(event.origin)) {
console.warn(
`Rejected message from unauthorized origin: ${event.origin}`
);
return;
}
// STEP 2: Validate message shape with Zod
const result = widgetMessageSchema.safeParse(event.data);
if (!result.success) {
console.warn("Rejected malformed message:", result.error.format());
return;
}
// STEP 3: Handle validated, typed message
const message = result.data;
switch (message.type) {
case "WIDGET_READY":
setWidgetReady(true);
setWidgetVersion(message.payload.version);
break;
case "WIDGET_EVENT":
// Safe to use — shape is validated
console.log(
`Widget ${message.payload.action}: ${message.payload.target}`
);
break;
case "WIDGET_ERROR":
console.error(
`Widget error [${message.payload.code}]: ${message.payload.message}`
);
break;
}
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
const sendToWidget = useCallback(
(message: ParentMessage) => {
if (!iframeRef.current?.contentWindow) return;
// ALWAYS specify exact origin — never use "*"
iframeRef.current.contentWindow.postMessage(
message,
"https://widget.example.com"
);
},
[]
);
return (
<div>
<h1>Dashboard</h1>
{widgetReady && <p>Widget v{widgetVersion} connected</p>}
<button
disabled={!widgetReady}
onClick={() =>
sendToWidget({
type: "THEME_UPDATE",
payload: { mode: "dark", primaryColor: "#3b82f6", fontSize: 16 },
})
}
>
Dark Theme
</button>
<iframe
ref={iframeRef}
src="https://widget.example.com/embed"
title="Widget"
sandbox="allow-scripts allow-same-origin"
style={{ width: 600, height: 400, border: "1px solid #e2e8f0" }}
// Permissions Policy restricts what the iframe can access
allow="clipboard-read; clipboard-write"
/>
</div>
);
}Secure Widget (inside iframe)
import { useEffect, useCallback } from "react";
import { parentMessageSchema, type WidgetMessage } from "./schemas/widget-messages";
// The widget knows exactly which parent should embed it
const ALLOWED_PARENT_ORIGINS = new Set([
"https://dashboard.example.com",
"https://staging.dashboard.example.com",
]);
function SecureWidget() {
useEffect(() => {
function handleMessage(event: MessageEvent) {
// Validate origin
if (!ALLOWED_PARENT_ORIGINS.has(event.origin)) {
console.warn(`Rejected message from: ${event.origin}`);
return;
}
// Validate shape
const result = parentMessageSchema.safeParse(event.data);
if (!result.success) {
// Report malformed message back to parent
sendToParent({
type: "WIDGET_ERROR",
payload: { code: "INVALID_MESSAGE", message: "Malformed message received" },
});
return;
}
const message = result.data;
switch (message.type) {
case "THEME_UPDATE":
applyTheme(message.payload);
break;
case "DATA_UPDATE":
renderData(message.payload.items);
break;
case "CONFIG_UPDATE":
updateConfig(message.payload);
break;
}
}
window.addEventListener("message", handleMessage);
// Announce readiness
sendToParent({
type: "WIDGET_READY",
payload: { version: "1.4.0", capabilities: ["chart", "table"] },
});
return () => window.removeEventListener("message", handleMessage);
}, []);
function sendToParent(message: WidgetMessage) {
if (window.parent === window) return; // Not embedded
// Send only to the known parent origin
// If embedded by an unknown origin, this message goes nowhere (safe)
for (const origin of ALLOWED_PARENT_ORIGINS) {
if (document.referrer.startsWith(origin)) {
window.parent.postMessage(message, origin);
return;
}
}
// No matching parent origin — refuse to communicate
console.error("Cannot determine parent origin; not sending message");
}
return <div>Widget Content</div>;
}Deep Dive
Origin Validation Patterns
The allowlist is the only safe pattern. Compare the full origin string against a Set of known-good values.
// SAFE: exact match via Set lookup
const ALLOWED = new Set(["https://app.example.com", "https://staging.example.com"]);
if (!ALLOWED.has(event.origin)) return;
// SAFE: exact match via strict equality (single origin)
if (event.origin !== "https://app.example.com") return;Regex-based origin checks are dangerous. They are easy to get wrong and can be bypassed:
// DANGEROUS: dot in regex is not escaped
if (!/https:\/\/app.example.com/.test(event.origin)) return;
// Bypassed by: https://appXexample.com (dot matches any character)
// DANGEROUS: missing end anchor
if (!/https:\/\/app\.example\.com/.test(event.origin)) return;
// Bypassed by: https://app.example.com.evil.com
// DANGEROUS: substring check
if (!event.origin.includes("example.com")) return;
// Bypassed by: https://example.com.evil.com
// Bypassed by: https://notexample.com
// DANGEROUS: endsWith check
if (!event.origin.endsWith("example.com")) return;
// Bypassed by: https://evilexample.com
// LESS DANGEROUS but still fragile: anchored regex with escaped dots
if (!/^https:\/\/app\.example\.com$/.test(event.origin)) return;
// This works but is harder to maintain. Use a Set instead.Why "*" Target Origin is Dangerous
- When you call
target.postMessage(data, "*"), the browser delivers the message regardless of the target window's current origin. - If the iframe has navigated away (user clicked a link, redirect chain, etc.), your message goes to whatever page is now loaded in that iframe.
- If the message contains auth tokens, user data, or any sensitive information, that data is now exposed to an arbitrary origin.
- The only safe use of
"*"is for messages that are completely public and contain no sensitive information. Even then, prefer specifying the origin.
// DANGEROUS: token sent to whoever happens to be in the iframe
iframe.contentWindow!.postMessage({ token: authToken }, "*");
// SAFE: token only delivered if iframe origin matches
iframe.contentWindow!.postMessage({ token: authToken }, "https://widget.example.com");
// If the iframe has navigated away, the message is silently dropped.Preventing XSS via postMessage
postMessage is a well-known XSS vector. If you render message data in the DOM without sanitization, an attacker who can send messages to your window can inject arbitrary HTML.
// VULNERABLE: rendering unsanitized message data
function BadWidget() {
const [content, setContent] = useState("");
useEffect(() => {
window.addEventListener("message", (event) => {
// No origin check! No sanitization!
setContent(event.data.html);
});
}, []);
// dangerouslySetInnerHTML with untrusted data = XSS
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// SAFE: validate origin, validate shape, never use dangerouslySetInnerHTML
function SafeWidget() {
const [label, setLabel] = useState("");
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.origin !== "https://trusted.example.com") return;
const result = z.object({
type: z.literal("UPDATE_LABEL"),
payload: z.object({ label: z.string().max(100) }),
}).safeParse(event.data);
if (!result.success) return;
setLabel(result.data.payload.label);
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// React's JSX auto-escapes text content — safe
return <div>{label}</div>;
}Key XSS prevention rules:
- Never use
dangerouslySetInnerHTMLwith postMessage data. - Never pass message data to
eval(),new Function(), ordocument.write(). - Never use message data in
hreforsrcattributes without validating the URL scheme (blockjavascript:anddata:URIs). - React's JSX escaping protects you when rendering text content via
{variable}, but only if you do not bypass it withdangerouslySetInnerHTML. - Zod validation limits blast radius. If a field is constrained to
z.enum(["light", "dark"]), no injection is possible even if origin checks somehow fail.
Input Validation and Sanitization
// Defensive validation for different data types
const urlSchema = z.string().url().refine(
(url) => {
try {
const parsed = new URL(url);
return ["https:"].includes(parsed.protocol);
} catch {
return false;
}
},
{ message: "Only HTTPS URLs allowed" }
);
const htmlSafeStringSchema = z.string()
.max(1000)
.transform((s) => s.replace(/[<>&"']/g, (c) => {
const map: Record<string, string> = {
"<": "<", ">": ">", "&": "&",
'"': """, "'": "'",
};
return map[c] ?? c;
}));
// Constrain numeric ranges to prevent abuse
const paginationSchema = z.object({
page: z.number().int().min(1).max(10000),
pageSize: z.number().int().min(1).max(100),
});CSP frame-ancestors Directive
The frame-ancestors directive controls which origins can embed your page in an iframe. It is the modern replacement for X-Frame-Options.
# HTTP response header on the WIDGET's server
Content-Security-Policy: frame-ancestors https://dashboard.example.com https://staging.dashboard.example.com;
| Value | Effect |
|---|---|
frame-ancestors 'none' | Page cannot be embedded in any iframe |
frame-ancestors 'self' | Page can only be embedded by same-origin pages |
frame-ancestors https://example.com | Page can only be embedded by https://example.com |
frame-ancestors https://*.example.com | Wildcard subdomain matching |
frame-ancestorsis enforced by the browser before the iframe content loads. If the embedding origin is not in the list, the iframe shows a blank page or error.- This is defense in depth. Even if your JavaScript origin checks have a bug,
frame-ancestorsprevents unauthorized embedding at the protocol level. frame-ancestorscannot be set via a<meta>tag. It must be an HTTP response header.
Sandboxed Iframe Permissions
<!-- Minimal sandbox: only scripts, retains real origin -->
<iframe
src="https://widget.example.com"
sandbox="allow-scripts allow-same-origin"
/>
<!-- More permissive: forms and popups for OAuth -->
<iframe
src="https://auth-widget.example.com"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/>Critical sandbox rules:
allow-scripts+allow-same-origintogether means the iframe can remove its own sandbox attribute (if it can access its own<iframe>element in the parent DOM, which requires same-origin). For cross-origin iframes, this combination is safe because the iframe cannot access the parent DOM.- Without
allow-same-origin, the iframe's origin becomes the opaque origin"null". This breaks cookies, localStorage, and makes origin validation meaningless (you would have to check for the string"null", which any sandboxed iframe would also produce). - Without
allow-scripts, the iframe cannot execute JavaScript, which means postMessage communication is impossible from the iframe side. allow-top-navigationlets the iframe navigate the parent page. This is a phishing risk. Almost never add this.allow-top-navigation-by-user-activationis safer: it only allows navigation if the user clicked inside the iframe.
Content Security Policy for Iframe Embedding
Both the parent and iframe should have CSP headers:
# Parent page CSP (controls what the parent page can do)
Content-Security-Policy:
default-src 'self';
frame-src https://widget.example.com https://widget-staging.example.com;
script-src 'self';
style-src 'self' 'unsafe-inline';
# Widget page CSP (controls what the widget can do)
Content-Security-Policy:
default-src 'self';
frame-ancestors https://dashboard.example.com;
script-src 'self';
connect-src 'self' https://api.example.com;
| Directive | Where | Purpose |
|---|---|---|
frame-src | Parent | Which origins the parent can embed as iframes |
frame-ancestors | Widget | Which origins can embed the widget |
script-src | Both | Which scripts can execute |
connect-src | Widget | Which APIs the widget can call (fetch, XHR, WebSocket) |
style-src | Both | Which stylesheets can load |
Rate-Limiting Incoming Messages
// Prevent message flooding from a compromised or malicious iframe
function createRateLimitedHandler(
handler: (event: MessageEvent) => void,
maxPerSecond: number
) {
const timestamps: number[] = [];
return (event: MessageEvent) => {
const now = Date.now();
// Remove timestamps older than 1 second
while (timestamps.length > 0 && timestamps[0] < now - 1000) {
timestamps.shift();
}
if (timestamps.length >= maxPerSecond) {
console.warn(`Rate limit exceeded for messages from ${event.origin}`);
return;
}
timestamps.push(now);
handler(event);
};
}
// Usage
useEffect(() => {
const handler = createRateLimitedHandler((event) => {
if (event.origin !== EXPECTED_ORIGIN) return;
// ... handle message
}, 50); // Max 50 messages per second
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);Gotchas
- Origin
"null"(the string) is a valid origin for sandboxed iframes withoutallow-same-origin,data:URIs, andfile:pages. Never add"null"to your allowlist because it would match all of those sources indiscriminately. event.originis always a string, even fornullorigins. Check with===, not==. The string"null"is truthy.- Browser extensions can postMessage. Extensions inject content scripts that share the page's JavaScript context and can call
window.postMessage. Your origin check will see the page's own origin for these messages. You cannot distinguish extension messages from same-origin page messages via origin alone. Use a shared secret or nonce established through a trusted channel. document.referreris not a reliable origin check. It can be spoofed with theReferrer-Policyheader and is not populated in all navigation scenarios. Use it as a hint for determining which parent to reply to, not as a security boundary.- JSON.parse of message data is unnecessary.
postMessageuses structured clone, not JSON serialization. The data arrives as a JavaScript object. If youJSON.stringifybefore sending andJSON.parseon receive, you lose type fidelity (Dates become strings, Maps are lost) and add unnecessary overhead. - CSP violations are silent by default. The browser blocks the resource and logs to the console, but the JavaScript on your page receives no notification. Use
Content-Security-Policy-Report-Onlywith areport-uriduring development to catch issues. - Subdomains are separate origins.
https://app.example.comandhttps://www.example.comare different origins. Your allowlist must include every subdomain variant explicitly. - HTTP and HTTPS are separate origins.
http://example.comandhttps://example.comare different. Always use HTTPS in production. Never add HTTP origins to your allowlist. - Port numbers matter.
https://example.com(port 443) andhttps://example.com:8443are different origins.
Alternatives
| Approach | When to Use |
|---|---|
| MessageChannel after validated handshake | After initial origin check, switch to ports for ongoing communication (no repeated origin checks) |
| Server-mediated communication | When you cannot trust either client to implement security correctly; route messages through your server |
| Signed messages (HMAC) | When you need to verify message integrity and authenticity beyond origin checks |
| OAuth redirect flow | For auth token exchange; avoids passing tokens through postMessage entirely |
| Trusted Types API | Browser-level protection against DOM XSS; complements postMessage validation |
| Permissions Policy (formerly Feature-Policy) | Restricts browser features (camera, geolocation, etc.) available to iframes |
FAQs
What are the three non-negotiable security checks for every postMessage handler?
- Validate origin -- reject messages from unknown senders using a
Setallowlist. - Validate shape -- parse
event.datawith a schema (e.g., Zod) to ensure it matches expected types. - Sanitize content -- if data will be rendered, ensure it cannot inject HTML or scripts.
Why are regex-based origin checks dangerous?
- An unescaped dot matches any character (e.g.,
app.example.commatchesappXexample.com). - A missing end anchor allows bypass via
app.example.com.evil.com. includes()andendsWith()are similarly vulnerable to subdomain spoofing.- Use exact-match via a
Setinstead.
How does Zod's discriminatedUnion help secure postMessage handling?
const parentMessageSchema = z.discriminatedUnion("type", [
themeUpdateSchema,
dataUpdateSchema,
configUpdateSchema,
]);
// Rejects any message that doesn't match one of the
// exact shapes, limiting blast radius of bad data.Gotcha: What is origin "null" (the string) and why should you never allow it?
- Sandboxed iframes without
allow-same-origin,data:URIs, andfile:pages all report the origin as the string"null". - Adding
"null"to your allowlist would match all of these sources indiscriminately. - Always require a real HTTPS origin.
How does CSP frame-ancestors differ from X-Frame-Options?
frame-ancestorsis the modern CSP replacement forX-Frame-Options.- It supports wildcard subdomains (e.g.,
https://*.example.com). - It must be set as an HTTP response header (cannot be set via a
<meta>tag). - It is enforced before the iframe content loads.
Why should you never use dangerouslySetInnerHTML with postMessage data?
- An attacker who can send messages to your window could inject arbitrary HTML and JavaScript.
- React's JSX
{variable}syntax auto-escapes text content, butdangerouslySetInnerHTMLbypasses this. - Also never pass message data to
eval(),new Function(), or unvalidatedhref/srcattributes.
How do you implement rate-limiting for incoming postMessage events in TypeScript?
function createRateLimitedHandler(
handler: (event: MessageEvent) => void,
maxPerSecond: number
) {
const timestamps: number[] = [];
return (event: MessageEvent) => {
const now = Date.now();
while (timestamps[0] < now - 1000) timestamps.shift();
if (timestamps.length >= maxPerSecond) return;
timestamps.push(now);
handler(event);
};
}What does the sandbox attribute allow-same-origin actually control?
- It determines whether the iframe retains its real origin or gets the opaque origin
"null". - Without it, cookies, localStorage, and origin-based validation all break.
- For cross-origin iframes, combining
allow-scripts+allow-same-originis safe because the iframe cannot access the parent DOM.
Gotcha: Can browser extensions send postMessage events that pass your origin check?
- Yes. Content scripts share the page's JavaScript context and call
window.postMessage. - Your origin check will see the page's own origin for these messages.
- You cannot distinguish them via origin alone; use a shared secret or nonce from a trusted channel.
Why is JSON.parse unnecessary for postMessage data?
postMessageuses structured clone, not JSON serialization. Data arrives as a JavaScript object.- If you
JSON.stringifybefore sending andJSON.parseon receive, you lose type fidelity (Datebecomes a string,Mapis lost) and add unnecessary overhead.
How should a widget determine which parent origin to reply to?
- Check
document.referreragainst the allowlist as a hint (not a security boundary). - Only send to a parent origin that matches an entry in
ALLOWED_PARENT_ORIGINS. - If no match is found, refuse to communicate.
What CSP directives should both the parent and widget pages set?
- Parent:
frame-src(which origins can be embedded),script-src,style-src. - Widget:
frame-ancestors(who can embed it),script-src,connect-src(which APIs it can call). - Use
Content-Security-Policy-Report-Onlyduring development to detect violations.
Related
- postMessage Fundamentals — MessageEvent anatomy, basic origin validation, typed protocols
- Intermediate postMessage Patterns — request/response, message bus hook, multi-iframe routing
- Advanced postMessage Patterns — MessageChannel, transferables, micro-frontend architecture, RPC layer