React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

postMessagesecurityCSPXSSiframesandboxZodbrowser-apis

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 dangerouslySetInnerHTML with postMessage data.
  • Never pass message data to eval(), new Function(), or document.write().
  • Never use message data in href or src attributes without validating the URL scheme (block javascript: and data: URIs).
  • React's JSX escaping protects you when rendering text content via {variable}, but only if you do not bypass it with dangerouslySetInnerHTML.
  • 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> = {
      "<": "&lt;", ">": "&gt;", "&": "&amp;",
      '"': "&quot;", "'": "&#x27;",
    };
    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;
ValueEffect
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.comPage can only be embedded by https://example.com
frame-ancestors https://*.example.comWildcard subdomain matching
  • frame-ancestors is 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-ancestors prevents unauthorized embedding at the protocol level.
  • frame-ancestors cannot 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-origin together 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-navigation lets the iframe navigate the parent page. This is a phishing risk. Almost never add this.
  • allow-top-navigation-by-user-activation is 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;
DirectiveWherePurpose
frame-srcParentWhich origins the parent can embed as iframes
frame-ancestorsWidgetWhich origins can embed the widget
script-srcBothWhich scripts can execute
connect-srcWidgetWhich APIs the widget can call (fetch, XHR, WebSocket)
style-srcBothWhich 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 without allow-same-origin, data: URIs, and file: pages. Never add "null" to your allowlist because it would match all of those sources indiscriminately.
  • event.origin is always a string, even for null origins. 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.referrer is not a reliable origin check. It can be spoofed with the Referrer-Policy header 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. postMessage uses structured clone, not JSON serialization. The data arrives as a JavaScript object. If you JSON.stringify before sending and JSON.parse on 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-Only with a report-uri during development to catch issues.
  • Subdomains are separate origins. https://app.example.com and https://www.example.com are different origins. Your allowlist must include every subdomain variant explicitly.
  • HTTP and HTTPS are separate origins. http://example.com and https://example.com are different. Always use HTTPS in production. Never add HTTP origins to your allowlist.
  • Port numbers matter. https://example.com (port 443) and https://example.com:8443 are different origins.

Alternatives

ApproachWhen to Use
MessageChannel after validated handshakeAfter initial origin check, switch to ports for ongoing communication (no repeated origin checks)
Server-mediated communicationWhen 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 flowFor auth token exchange; avoids passing tokens through postMessage entirely
Trusted Types APIBrowser-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?
  1. Validate origin -- reject messages from unknown senders using a Set allowlist.
  2. Validate shape -- parse event.data with a schema (e.g., Zod) to ensure it matches expected types.
  3. 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.com matches appXexample.com).
  • A missing end anchor allows bypass via app.example.com.evil.com.
  • includes() and endsWith() are similarly vulnerable to subdomain spoofing.
  • Use exact-match via a Set instead.
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, and file: 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-ancestors is the modern CSP replacement for X-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, but dangerouslySetInnerHTML bypasses this.
  • Also never pass message data to eval(), new Function(), or unvalidated href/src attributes.
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-origin is 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?
  • postMessage uses structured clone, not JSON serialization. Data arrives as a JavaScript object.
  • If you JSON.stringify before sending and JSON.parse on receive, you lose type fidelity (Date becomes a string, Map is lost) and add unnecessary overhead.
How should a widget determine which parent origin to reply to?
  • Check document.referrer against 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-Only during development to detect violations.