MCP Server Security · BroadcastChannel
MCP server BroadcastChannel security — cross-tab message filtering, origin isolation, and unexpected source prevention
BroadcastChannel is a browser API for sending messages between same-origin tabs, workers, and iframes. Browser-based MCP client UIs use it for cross-tab session coordination — broadcasting tool call results to a dashboard tab, or notifying all open Claude sessions of a server reconnect. The security risk: any same-origin page can post to any channel by name. A compromised tab, an XSS payload in a different route, or a malicious extension with same-origin permissions can inject tool invocation messages into your MCP session channel.
How BroadcastChannel origin isolation actually works
BroadcastChannel is scoped to the tuple of (origin, channel name). A channel named "mcp-sessions" on https://app.example.com is completely isolated from the same channel name on https://api.example.com — different origin. But all tabs and workers on the same origin share the channel namespace.
// Tab A — legitimate MCP client
const ch = new BroadcastChannel('mcp-tool-results');
ch.onmessage = (event) => {
// event.origin is ALWAYS the current origin for BroadcastChannel
// You cannot use event.origin to filter senders here — it's always the same origin
console.log(event.data); // could be from ANY same-origin tab
};
// Tab B — same origin, malicious extension or XSS payload
const attack = new BroadcastChannel('mcp-tool-results');
attack.postMessage({ type: 'TOOL_RESULT', result: '<script>stealCookies()</script>' });
event.origin is useless for BroadcastChannel sender verification. Unlike postMessage between cross-origin windows (where event.origin identifies the sender's origin), BroadcastChannel message events always show the receiver's own origin in event.origin. You cannot use it to distinguish which tab sent the message.
Secure BroadcastChannel pattern: session-scoped channels with HMAC validation
The correct defense has three parts: (1) use a secret, per-session channel name that XSS payloads cannot guess, (2) validate every message against a Zod schema before acting on it, (3) sign messages with a session-specific key so injected messages fail signature verification.
import { z } from 'zod';
// 1. Per-session channel name — unguessable by XSS or other tabs
// Stored in sessionStorage (not accessible from other tabs)
function getOrCreateChannelName() {
let name = sessionStorage.getItem('mcp_channel');
if (!name) {
name = `mcp-${crypto.randomUUID()}`;
sessionStorage.setItem('mcp_channel', name);
}
return name;
}
// 2. Per-session HMAC signing key (non-extractable, in-memory only)
async function generateChannelKey() {
return crypto.subtle.generateKey(
{ name: 'HMAC', hash: 'SHA-256' },
false, // non-extractable: XSS can't export it
['sign', 'verify']
);
}
// 3. Message schema (reject anything that doesn't match)
const McpBroadcastMessage = z.object({
type: z.enum(['TOOL_RESULT', 'SESSION_STATE', 'RECONNECT']),
sessionId: z.string().uuid(),
payload: z.unknown(),
sig: z.string(), // base64url HMAC signature
});
// Signing
async function signMessage(key, message) {
const text = JSON.stringify({ type: message.type, sessionId: message.sessionId, payload: message.payload });
const encoded = new TextEncoder().encode(text);
const sigBytes = await crypto.subtle.sign('HMAC', key, encoded);
return btoa(String.fromCharCode(...new Uint8Array(sigBytes)));
}
// Verification + dispatch
async function handleBroadcast(key, event) {
let parsed;
try {
parsed = McpBroadcastMessage.parse(event.data);
} catch {
return; // Silently drop malformed messages
}
const expectedSig = await signMessage(key, parsed);
if (parsed.sig !== expectedSig) {
console.warn('[BroadcastChannel] Signature mismatch — dropping message');
return;
}
// Safe to process
dispatchToolResult(parsed);
}
// Setup
const channelName = getOrCreateChannelName();
const channelKey = await generateChannelKey();
const ch = new BroadcastChannel(channelName);
ch.addEventListener('message', (e) => handleBroadcast(channelKey, e));
sessionStorage vs localStorage scope. sessionStorage is per-tab (not shared between tabs). Use it only for the channel name in single-tab flows where the channel is internal to one tab. For multi-tab coordination where tabs need to find each other's channels, derive the channel name from a shared session token stored in localStorage (or a cookie), but keep the HMAC key in-memory only.
Multi-tab MCP session coordination: safe patterns
A common legitimate use case: user has two Claude tabs open; the MCP server reconnects and all tabs should display the reconnect banner. The safe pattern broadcasts a notification with the session ID, not the session credentials:
// What to broadcast: minimal, non-sensitive coordination signals
channel.postMessage(await signed(key, {
type: 'RECONNECT',
sessionId: currentSessionId,
payload: { reconnectedAt: Date.now() }, // no tokens, no credentials
}));
// What NOT to broadcast: access tokens, session secrets, tool output with PII
// These should stay in the originating tab and be fetched by other tabs
// via the MCP server, not via BroadcastChannel
SkillAudit findings
See also: postMessage security · SharedArrayBuffer security · SSRF advanced patterns
Audit your MCP server for BroadcastChannel vulnerabilities
SkillAudit detects predictable channel names, missing message validation, and cross-tab injection surface automatically.
Run free audit →