Skip to content
blog

postMessage Security: Checking event.origin the Right Way

A message handler without an origin check is an open API.

What I kept seeing in the Handlers tab

When I added the Handlers tab to PostMessage Inspector, the goal was purely practical: show which listener reacted to a message and where it was registered. It shows the listener's source too, mostly so you can recognise it.

Reading those sources every day changed how I look at message handlers. Many of them start like this:

js
window.addEventListener('message', (event) => { if (event.data.type === 'SET_TOKEN') { auth.setToken(event.data.token); } });

There's no line about who sent the message. This handler will happily accept a token from any window that can reach it.

Why anyone can message you

postMessage exists precisely to cross origins. The browser delivers the message to any window the sender has a reference to — and getting a reference is easy:

  • a page that embeds yours in an iframe gets iframe.contentWindow;
  • a page that opens yours with window.open gets the returned window;
  • your page gets window.opener and window.parent back, and so does any frame inside it;
  • any frame can walk window.top.frames to reach its siblings.

The browser doesn't decide whether a message is welcome. It stamps it with facts you can't fake — event.origin (the sender's origin) and event.source (the sender's window) — and leaves the decision to you. A handler that ignores them has made the decision already: everyone is welcome.

Checks that look right but aren't

The more interesting cases aren't the missing checks — those are easy to spot. They're the checks that are there and still let attackers in. Say we only trust https://trusted.com:

js
// ❌ substring: passes https://nottrusted.com and https://trusted.com.evil.io if (event.origin.includes('trusted.com')) { … } if (event.origin.indexOf('trusted.com') !== -1) { … } // ❌ prefix: passes https://trusted.com.evil.io if (event.origin.startsWith('https://trusted.com')) { … } // ❌ suffix: passes https://eviltrusted.com if (event.origin.endsWith('trusted.com')) { … } // ❌ regex: unescaped dots and no anchors, passes https://trustedxcom.io if (/https:\/\/trusted.com/.test(event.origin)) { … }

Every one of these attacker origins is a domain anyone can register for a few dollars. Subdomain checks are especially easy to get wrong, because "ends with trusted.com" and "is a subdomain of trusted.com" are not the same thing.

Try it: pick an origin or type your own and see which checks it gets past. Only https://trusted.com should pass; anything else that turns green is an attacker getting in.

jsx
import { useState } from 'react'; import './styles.css'; const TRUSTED = 'https://trusted.com'; const ALLOWED_ORIGINS = new Set([TRUSTED]); const CHECKS = [ { code: "origin.includes('trusted.com')", test: (o) => o.includes('trusted.com') }, { code: "origin.startsWith('https://trusted.com')", test: (o) => o.startsWith('https://trusted.com') }, { code: "origin.endsWith('trusted.com')", test: (o) => o.endsWith('trusted.com') }, { code: '/https:\\/\\/trusted.com/.test(origin)', test: (o) => /https:\/\/trusted.com/.test(o) }, { code: 'ALLOWED_ORIGINS.has(origin)', test: (o) => ALLOWED_ORIGINS.has(o) }, ]; // Domains anyone can register for a few dollars. const PRESETS = [ 'https://trusted.com', 'https://nottrusted.com', 'https://trusted.com.evil.io', 'https://eviltrusted.com', 'https://trustedxcom.io', ]; export default function App() { const [origin, setOrigin] = useState(PRESETS[2]); const legit = origin === TRUSTED; return ( <main> <label> event.origin <input value={origin} onChange={(e) => setOrigin(e.target.value.trim())} spellCheck={false} /> </label> <div className="presets"> {PRESETS.map((p) => ( <button key={p} className={p === origin ? 'active' : ''} onClick={() => setOrigin(p)}> {p.replace('https://', '')} </button> ))} </div> <table> <tbody> {CHECKS.map(({ code, test }) => { const passes = test(origin); const right = passes === legit; return ( <tr key={code} className={right ? 'ok' : 'bad'}> <td><code>{code}</code></td> <td>{passes ? 'lets it in' : 'blocks it'}</td> <td>{right ? '✅' : '🚨'}</td> </tr> ); })} </tbody> </table> </main> ); }

What a good handler looks like

An origin is just scheme, host and port — no path, no trailing slash. That makes the correct check boring, which is exactly what you want: exact comparison against an allowlist.

js
const ALLOWED_ORIGINS = new Set(['https://trusted.com', 'https://games.trusted.com']); window.addEventListener('message', (event) => { // 1. Who sent it: exact origin match if (!ALLOWED_ORIGINS.has(event.origin)) return; // 2. Which window sent it: the one you expect if (event.source !== gameFrame.contentWindow) return; // 3. What it says: validate the shape before using it const msg = event.data; if (typeof msg !== 'object' || msg === null || typeof msg.type !== 'string') return; switch (msg.type) { case 'balance:changed': if (typeof msg.amount === 'number') updateBalance(msg.amount); break; // unknown types are ignored, not guessed at } });

Each step catches something the previous one doesn't:

  • The origin proves which site sent it.
  • The source proves it came from the specific frame you think it did. Two frames from the same origin are different things: an old frame you forgot about, or a second widget from the same provider.
  • Validation protects you from a trusted sender who sends something unexpected — a newer protocol version, a bug, or a compromised third-party script. Never pass message data to innerHTML, eval, location or new Function without checking it.

If you really need "any subdomain", parse it instead of matching strings:

js
function isTrustedSubdomain(origin) { const { protocol, hostname } = new URL(origin); return protocol === 'https:' && (hostname === 'trusted.com' || hostname.endsWith('.trusted.com')); }

Note the leading dot in '.trusted.com' — that's the whole difference from the broken suffix check above.

The same rule applies outside postMessage. A WebSocket handshake carries an Origin too, and your server has to check it the same way.

Don't trust "null"

Sandboxed iframes without allow-same-origin, data: URLs and some file: pages send messages with event.origin === "null" — the string. Every one of them looks the same, so putting "null" in an allowlist means trusting all of them at once. If you have to talk to a sandboxed frame, check event.source against that exact frame instead.

The other half: targetOrigin

Checking incoming messages protects you. The second argument of postMessage protects the data you send:

js
// ❌ whoever is in that frame right now gets the token gameFrame.contentWindow.postMessage({ type: 'auth', token }, '*'); // ✅ delivered only if the frame is still on that origin gameFrame.contentWindow.postMessage({ type: 'auth', token }, 'https://games.trusted.com');

Frames navigate. An ad slot, a redirect after an error, a provider that moved its game to another domain — and the window you hold a reference to now shows a different site. With '*', the browser delivers the message anyway. With a specific origin, it silently drops it. Use '*' only for data you'd be fine posting publicly.

Better still: one handshake, then a private channel

If two windows exchange many messages, you can check origins once and then talk through a MessageChannel. Messages on a port can only come from whoever holds the other end, so no other window can inject anything into that conversation:

js
// Host page const GAME_ORIGIN = 'https://games.trusted.com'; gameFrame.addEventListener('load', () => { const { port1, port2 } = new MessageChannel(); port1.onmessage = (e) => handleGameMessage(e.data); gameFrame.contentWindow.postMessage({ type: 'connect' }, GAME_ORIGIN, [port2]); });
js
// Inside the game frame const HOST_ORIGIN = 'https://trusted.com'; let port = null; window.addEventListener('message', (e) => { if (port) return; // already connected if (e.origin !== HOST_ORIGIN || e.source !== window.parent) return; if (e.data?.type !== 'connect' || !e.ports[0]) return; port = e.ports[0]; port.onmessage = (msg) => handleHostMessage(msg.data); port.postMessage({ type: 'ready' }); });

The handshake still needs the same origin and source checks — it's the one place where an attacker could try to hand you their port. After that, the channel is private. (Validating the data still applies.)

A checklist for every message handler

  • event.origin is compared exactly against an allowlist — no includes, startsWith, endsWith or unanchored regex.
  • "null" is not in the allowlist.
  • event.source is checked when you expect one specific frame.
  • event.data is validated before use; unknown message types are ignored.
  • Message data never reaches innerHTML, eval, new Function or location unchecked.
  • Outgoing messages use a specific targetOrigin, not '*'.
  • Long conversations move to a MessageChannel after a checked handshake.

Teaching the inspector to spot this

Right now PostMessage Inspector shows you each handler's source, and you do the reading. The next step on the roadmap is to do some of that reading for you:

  • warn when a handler's source never mentions origin at all;
  • flag the weak patterns from this post — indexOf, includes, startsWith, unanchored regexes on the origin;
  • a filter for "messages handled without an origin check";
  • highlight payloads that look like secrets — tokens, passwords, JWTs.

It will be a heuristic, not a security audit: the extension sees source text, not what the code really does. But "this handler never checks origin" is exactly the kind of thing that's easy to miss in review and obvious once something points at it.

If you want to look at your own app's handlers today, PostMessage Inspector is on the Chrome Web Store and Firefox Add-ons — open the Handlers tab and read what your listeners actually check.