Blog · 2026-06-21 · Fetch Metadata · XS-Leaks · Resource Isolation · MCP Servers

MCP Server Fetch Metadata Security: Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, Resource Isolation Policy, and XS-Leak Defense

Fetch Metadata request headers — Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, and Sec-Fetch-User — are set by the browser on every request and classified as Forbidden request headers, meaning JavaScript running in the page cannot overwrite or remove them. An MCP server tool endpoint that reads these four headers gains a server-side view of how each request was initiated — legitimate same-origin API fetch, cross-site iframe probe, navigation injection attempt, or CSS import timing oracle — before touching auth logic, CSRF tokens, or business data. A resource isolation policy built on these headers blocks the entire class of XS-Leak attacks that probe for private resource existence by observing error codes and response timing.

Why Fetch Metadata exists and why it matters specifically for MCP servers

Traditional server-side request handling has no reliable way to distinguish a fetch('/api/mcp/invoke', { method: 'POST', credentials: 'include' }) call from legitimate application code from the same call crafted by an attacker's cross-site page. Both carry the user's session cookies. Both carry the correct Content-Type: application/json header. Both may carry a CSRF token if the attacker could obtain one via a same-origin read. The server's only defense before Fetch Metadata was the Origin and Referer headers — both strippable, both spoofable in non-browser clients, and both unreliable under various redirect and caching conditions.

Fetch Metadata changes this. The browser inserts the Sec-Fetch-* headers on every request and marks them as Forbidden — the XMLHttpRequest and fetch()` APIs will throw or silently ignore any attempt by JavaScript to set a header beginning with Sec-. An attacker's page cannot instruct the browser to forge these headers. The only way to send a request with Sec-Fetch-Site: same-origin is to be on the same origin.

MCP servers face a particular XS-Leak exposure because their tool endpoints often return structured responses that vary based on internal state: a tool call to check_user_permission may return 200 OK for authorized users and 403 Forbidden for unauthorized users. A cross-origin attacker can probe this endpoint using a cross-origin image, script, or iframe load — the attacker cannot read the response body due to CORS, but can observe the HTTP status code, the load vs. error event on the element, or the response timing. Fetch Metadata lets you reject any request to a tool endpoint that does not originate from the same site, making the entire status-differential probe impossible before the handler even runs.

The four Sec-Fetch-* headers

Sec-Fetch-Site

Sec-Fetch-Site describes the relationship between the request's origin and the destination resource's origin. It has four values:

ValueMeaningExample scenario
same-originRequest origin and resource origin are identical (scheme + host + port)Your app at skillaudit.dev fetching skillaudit.dev/api/tools
same-siteSame eTLD+1 but possibly different subdomain or schemeapp.skillaudit.dev fetching api.skillaudit.dev/tools
cross-siteDifferent registrable domainAttacker page at evil.com fetching skillaudit.dev/api/tools
noneUser-initiated navigation with no initiating document (direct URL bar entry, bookmark, browser extension)User types URL directly; Cmd+click from email client

For MCP tool invocation endpoints, the only legitimate values are same-origin and, if you use cross-subdomain architecture, same-site. A request arriving with Sec-Fetch-Site: cross-site to a tool endpoint is either a misconfigured third-party integration or an attack. A resource isolation policy that rejects cross-site eliminates CSRF, XS-Leak status probing, and navigation injection in one check.

Sec-Fetch-Mode

Sec-Fetch-Mode describes the request mode — the type of browser fetch operation that initiated the request. The values map to the mode option in the fetch() API and to browser-initiated request types:

ValueMeaningAttack relevance
corsExplicit CORS request (fetch(url, { mode: 'cors' })); browser enforces CORS preflight and response headersLegitimate for cross-origin API fetches that the server explicitly allows via CORS
no-corsCross-origin request without CORS headers; response body is opaque (unreadable) to the callerUsed for cross-origin image, script, and stylesheet loads — attacker can load resource and observe load/error event but cannot read response
same-originSame-origin fetch; browser enforces that the URL and caller are same-originLegitimate for your application's own API calls
navigateBrowser top-level navigation or iframe navigationCross-site navigation injection: attacker forces the browser to navigate to a URL and observes the resulting page
websocketWebSocket connection upgradeWebSocket CSRF: attacker page initiates a WebSocket to your server using the user's credentials

For MCP tool invocation endpoints that receive POST requests with JSON bodies, the only legitimate mode is cors (cross-origin callers with explicit CORS permission) or same-origin (same-origin callers). A request with Sec-Fetch-Mode: no-cors to a JSON API endpoint is suspicious — no-cors mode does not send the Content-Type: application/json header, so the request body would not be interpreted as JSON. Reject it.

Sec-Fetch-Dest

Sec-Fetch-Dest describes what the browser intends to do with the response — the destination context where the fetched resource will be consumed. This is the most granular header for distinguishing browser sub-resource loads (which an attacker can weaponize for XS-Leaks) from API fetches:

ValueSource operationAttack scenario
emptyfetch() or XMLHttpRequest with no specific destinationLegitimate API calls. Also used by attacker's fetch() cross-origin, but CORS blocks the response.
documentTop-level navigation (<a href>, window.location, form submit)Navigation injection: attacker navigates user to a tool endpoint URL
iframe<iframe src>iframe-based probing: attacker embeds your tool endpoint in an iframe and observes load/error and URL changes
image<img src>Image probing: attacker loads tool URL as an image; the HTTP status code (200 vs 404/403) determines whether load or error fires
script<script src>Script probing: attacker loads tool URL as script; load/error event reveals resource existence
style<link rel=stylesheet>, @importCSS import timing oracle: attacker times how long the "stylesheet" load takes to infer private data
workernew Worker(url)Worker error oracle: attacker checks if a URL parses as valid JS by observing Worker error event
object<object data>Object load/error event reveals existence

A resource isolation policy for MCP tool endpoints should allow Sec-Fetch-Dest: empty (legitimate API calls) and reject all other values for POST-method tool invocation paths. For GET-method resource endpoints (static assets, documentation pages), allow document, image, script, style from same-site origins but reject them from cross-site origins.

Sec-Fetch-User

Sec-Fetch-User has only one value: ?1. It is present only on navigation requests that were triggered by a user gesture (mouse click, keyboard enter, form submit via user interaction). It is absent on programmatically initiated navigations. This is a minor signal but useful: a Sec-Fetch-Mode: navigate request without Sec-Fetch-User: ?1 is a programmatic navigation — a redirect chain, an auto-submit, or a navigation injection from an attacker's page — not a genuine user click.

// Reading all four headers and logging the request context
app.use('/api/mcp', (req, res, next) => {
  const fetchSite = req.headers['sec-fetch-site'];
  const fetchMode = req.headers['sec-fetch-mode'];
  const fetchDest = req.headers['sec-fetch-dest'];
  const fetchUser = req.headers['sec-fetch-user'];

  console.log('Fetch Metadata:', { fetchSite, fetchMode, fetchDest, fetchUser });
  // Output for a legitimate same-origin fetch:
  // { fetchSite: 'same-origin', fetchMode: 'cors', fetchDest: 'empty', fetchUser: undefined }
  // Output for a cross-site image probe:
  // { fetchSite: 'cross-site', fetchMode: 'no-cors', fetchDest: 'image', fetchUser: undefined }
  // Output for a user-initiated top-level navigation:
  // { fetchSite: 'none', fetchMode: 'navigate', fetchDest: 'document', fetchUser: '?1' }

  next();
});

The Forbidden header restriction: why these headers cannot be forged

The security guarantee of Fetch Metadata depends entirely on the browser enforcing the Forbidden header restriction. Headers prefixed with Sec- are classified as Forbidden request headers in the Fetch specification. The XMLHttpRequest and fetch() APIs refuse to set any header on the Forbidden list — the assignment is silently ignored or throws a TypeError depending on the API.

// Browser JavaScript — attempt to forge Sec-Fetch-Site
try {
  await fetch('/api/mcp/invoke', {
    headers: {
      'Sec-Fetch-Site': 'same-origin',  // Attacker tries to forge as same-origin
      'Sec-Fetch-Mode': 'same-origin',
    }
  });
} catch (e) {
  // In some browsers this throws TypeError: Failed to execute 'fetch' on 'Window':
  // Headers starting with 'Sec-' are forbidden.
  // In others the headers are silently dropped.
}

// The actual request sent to the server will have:
//   Sec-Fetch-Site: cross-site  (set by the browser based on actual origins)
//   Sec-Fetch-Mode: cors        (set by the browser based on fetch mode)
// The attacker's injected headers are ignored.

curl and non-browser clients: curl, Postman, and direct HTTP clients are not browsers — they have no Forbidden header restriction. A direct API call from curl will send requests without any Sec-Fetch-* headers. Your resource isolation policy must handle missing headers gracefully: treat absence of Sec-Fetch-Site as a non-browser request and either allow it (if your API has non-browser consumers) or reject it only on sensitive state-changing endpoints that should only be called by your browser UI. Never assume absence of the header means the request is safe — it means it cannot be classified.

Building a resource isolation policy for MCP tool endpoints

A resource isolation policy is a middleware that classifies every incoming request using the Sec-Fetch-* headers and rejects requests that cannot plausibly originate from legitimate application code. The policy runs before authentication, authorization, CSRF validation, or any business logic — it's a pre-filter that eliminates entire attack classes before they reach handler code.

The Google Web.dev team published the canonical resource isolation policy algorithm. For MCP servers, the relevant variant is:

// Express middleware: Resource Isolation Policy for MCP tool endpoints
function resourceIsolationPolicy(options = {}) {
  const {
    // Origins explicitly allowed cross-site (e.g. mobile app origin)
    allowedCrossOrigins = [],
    // Paths that may be fetched cross-site (e.g. public badge endpoint)
    allowedPaths = [],
    // Whether to reject non-browser requests (no Sec-Fetch-* headers)
    requireBrowserContext = false,
  } = options;

  return (req, res, next) => {
    const site = req.headers['sec-fetch-site'];
    const mode = req.headers['sec-fetch-mode'];
    const dest = req.headers['sec-fetch-dest'];

    // 1. No Sec-Fetch-* headers — non-browser client (curl, Postman, SDK)
    if (!site) {
      if (requireBrowserContext) {
        return res.status(403).json({ error: 'Browser context required' });
      }
      // Allow non-browser clients through — they have separate auth requirements
      return next();
    }

    // 2. Same-origin requests are always allowed
    if (site === 'same-origin') {
      return next();
    }

    // 3. same-site requests allowed for cross-subdomain architecture
    if (site === 'same-site') {
      return next();
    }

    // 4. User-initiated top-level navigation (none) to non-sensitive GET endpoints: allow
    if (site === 'none' && mode === 'navigate' && req.method === 'GET') {
      return next();
    }

    // 5. Explicitly allowed cross-origin paths (e.g. public badge CDN fetch)
    if (allowedPaths.some(p => req.path.startsWith(p))) {
      return next();
    }

    // 6. Explicitly allowed cross-origins (e.g. native app OAuth callback)
    const origin = req.headers['origin'];
    if (origin && allowedCrossOrigins.includes(origin)) {
      return next();
    }

    // 7. Everything else is cross-site — reject
    console.warn('Resource isolation policy: rejected cross-site request', {
      site, mode, dest,
      method: req.method,
      path: req.path,
      origin: req.headers['origin'],
      referer: req.headers['referer'],
    });
    return res.status(403).json({ error: 'Cross-site request rejected by resource isolation policy' });
  };
}

// Apply to all MCP tool endpoints
app.use('/api/mcp', resourceIsolationPolicy({
  allowedPaths: ['/api/mcp/badge/'],  // Public badge endpoint
  requireBrowserContext: false,        // Allow SDK/CLI callers without headers
}));

Policy placement: Run the resource isolation middleware first in your middleware stack on tool endpoints — before rate limiting, before CSRF validation, before JWT verification. Cross-site probes that reach your auth logic consume auth processing time and may produce timing signals that leak information about user existence. The isolation policy eliminates the probe before any state is read.

XS-Leak vectors blocked by Fetch Metadata validation

Cross-Site Leaks (XS-Leaks) are a class of browser-side side-channel attacks that exploit the fact that browser APIs behave differently depending on the HTTP response to a cross-origin request. The attacker cannot read the response body (same-origin policy blocks that), but can observe secondary signals: the load vs. error event, the response timing, the number of frames in an iframe, the scroll position, and more. Each signal can encode a bit of information about the server's internal state.

Fetch Metadata blocks all probe types that use sub-resource load as the sensing mechanism:

Image probe (error/load oracle)

Attacker loads <img src="https://skillaudit.dev/api/mcp/check-user-exists?email=victim@corp.com">. If the endpoint returns 200, the image load event fires. If 404, the error event fires. The attacker learns whether the email is registered without reading the response.

Fetch Metadata defense: Sec-Fetch-Dest: image with Sec-Fetch-Site: cross-site — policy rejects before handler runs. Neither load nor error event fires.

Script probe (parse oracle)

Attacker loads a tool endpoint as <script src>. If the response parses as valid JavaScript, the script global executes; if not, an error fires. Even a 200 response with non-JS content produces a distinguishable signal from a 403 with a JSON error body.

Fetch Metadata defense: Sec-Fetch-Dest: script with Sec-Fetch-Site: cross-site — policy rejects. No parse attempt, no signal.

CSS import timing oracle

Attacker loads a data-dependent endpoint as @import url(). The parse time of a large CSS response encodes the length of the response body, which may encode the number of records returned by a query (e.g., "how many audit results does this organization have?").

Fetch Metadata defense: Sec-Fetch-Dest: style with Sec-Fetch-Site: cross-site — policy rejects. No CSS fetch, no timing signal.

iframe frame-count oracle

Attacker embeds a tool result page in a cross-origin iframe and reads iframe.contentWindow.frames.length. The frame count can differ based on what content was rendered (authenticated view shows sidebar frame; error view does not), leaking auth state.

Fetch Metadata defense: Sec-Fetch-Dest: iframe with Sec-Fetch-Site: cross-site — policy rejects. Iframe loads an error page that has a known frame structure, eliminating the signal.

Navigation injection: Sec-Fetch-Mode: navigate as an attack signal

Navigation injection is an attack where an adversary forces the browser to navigate to a URL on a trusted site, either to exfiltrate data encoded in the URL path or to trigger a side-effecting GET handler. If your MCP server exposes any GET endpoint that mutates state — a common pattern in legacy APIs that use GET for "quick actions" — navigation injection is a one-click CSRF without any form or token.

The Fetch Metadata signal for navigation injection is clear: Sec-Fetch-Mode: navigate combined with Sec-Fetch-Site: cross-site. Legitimate top-level navigations from bookmarks and the URL bar arrive with Sec-Fetch-Site: none, not cross-site. A cross-site + navigate combination is a programmatic cross-site navigation — a link click on an attacker page, an iframe redirect, or a window.location assignment from a cross-origin window.

// Detecting navigation injection specifically
app.use('/api/mcp', (req, res, next) => {
  const site = req.headers['sec-fetch-site'];
  const mode = req.headers['sec-fetch-mode'];
  const user = req.headers['sec-fetch-user'];

  // Cross-site navigation to an API endpoint is never legitimate
  if (site === 'cross-site' && mode === 'navigate') {
    return res.status(403).json({ error: 'Cross-site navigation to API endpoint rejected' });
  }

  // Programmatic (no user gesture) navigation from same site — suspicious but allow with log
  if ((site === 'same-site' || site === 'same-origin') && mode === 'navigate' && !user) {
    console.warn('Programmatic navigation (no user gesture) to API endpoint', {
      path: req.path,
      site,
      mode,
    });
  }

  next();
});

WebSocket CSRF and Sec-Fetch-Mode: websocket

WebSocket handshakes in MCP servers that use streaming tool output over WebSocket are CSRF targets. The WebSocket upgrade request is a cross-origin-capable request — the browser's same-origin policy does not prevent cross-origin WebSocket connections by default. An attacker page can open a WebSocket to your server, and the browser will send the user's session cookies on the upgrade request. The server needs to validate the Origin header on WebSocket upgrades, but Origin can be absent on some request paths.

Fetch Metadata adds a secondary signal: WebSocket upgrade requests arrive with Sec-Fetch-Mode: websocket and Sec-Fetch-Site set to the relationship between the opener's origin and the WebSocket URL's origin. A WebSocket connection initiated from evil.com to skillaudit.dev will send Sec-Fetch-Site: cross-site + Sec-Fetch-Mode: websocket.

// WebSocket upgrade validation with Fetch Metadata + Origin check
const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (req, socket, head) => {
  const origin = req.headers['origin'];
  const fetchSite = req.headers['sec-fetch-site'];
  const fetchMode = req.headers['sec-fetch-mode'];

  // Primary check: Origin header must be our domain
  const allowedOrigins = ['https://skillaudit.dev', 'https://app.skillaudit.dev'];
  if (!origin || !allowedOrigins.includes(origin)) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }

  // Secondary check: Fetch Metadata confirms this is a same-site WebSocket
  // (Absent Sec-Fetch-Site = non-browser client = still allowed, Origin check is primary)
  if (fetchMode === 'websocket' && fetchSite === 'cross-site') {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }

  wss.handleUpgrade(req, socket, head, ws => {
    wss.emit('connection', ws, req);
  });
});

Handling non-browser clients correctly

The most common mistake in Fetch Metadata policy implementations is rejecting all requests that lack Sec-Fetch-* headers. This breaks every legitimate non-browser consumer of your API: CLI tools built with the MCP SDK, CI pipeline scripts, mobile apps using native HTTP clients, server-to-server integrations, and curl commands from developer documentation.

The correct policy distinguishes between missing headers (non-browser — unknown origin, cannot classify) and the specific cross-site values that indicate browser-initiated cross-site requests:

// Correct: missing headers pass through (non-browser client)
// Wrong: missing headers = reject (breaks SDK, CLI, mobile)

function resourceIsolationPolicy(req, res, next) {
  const site = req.headers['sec-fetch-site'];

  // No Sec-Fetch headers — non-browser client, cannot classify origin
  // Pass through to authentication layer (JWT/API key handles these)
  if (site === undefined) {
    return next();
  }

  // Browser-sent header present — can enforce cross-origin policy
  if (site === 'cross-site') {
    return res.status(403).json({ error: 'Cross-site browser requests not permitted' });
  }

  next();
}

// For endpoints that should be browser-only (dashboard API, not public API):
function browserOnlyPolicy(req, res, next) {
  const site = req.headers['sec-fetch-site'];

  // Require Sec-Fetch headers — reject non-browser clients for this endpoint
  if (site === undefined) {
    return res.status(403).json({ error: 'This endpoint is browser-only — use the API endpoint for SDK access' });
  }

  if (site === 'cross-site') {
    return res.status(403).json({ error: 'Cross-site requests rejected' });
  }

  next();
}

Sec-Fetch-Dest: empty does not mean safe: Both legitimate same-origin API calls and attacker-controlled cross-origin fetch() calls with CORS mode send Sec-Fetch-Dest: empty. The empty value alone does not guarantee same-origin origin. Always pair Sec-Fetch-Dest checks with Sec-Fetch-Site. A cross-origin fetch() with CORS mode will be caught by the CORS preflight mechanism — but the Fetch Metadata policy lets you reject it even earlier.

Integrating Fetch Metadata with existing CSRF protection

Fetch Metadata is a complement to CSRF tokens, not a replacement. CSRF token validation provides defense in depth for browsers that do not send Sec-Fetch-* headers (primarily older Chromium-based browsers before 76, and Firefox before version 90 on some platforms). The correct layering is: Fetch Metadata policy first (fast reject for classified cross-site requests), then CSRF token validation (fallback for unclassifiable requests or non-browser clients that have obtained a legitimate token).

// Layered defense: Fetch Metadata first, CSRF token second
app.post('/api/mcp/invoke', [
  // Layer 1: Fast reject for classified cross-site browser requests
  resourceIsolationPolicy,

  // Layer 2: CSRF token validation for requests that passed layer 1
  // (unclassified non-browser clients must still provide a token if they received one)
  csrfProtection,

  // Layer 3: Authentication + authorization
  authenticateRequest,
  authorizeToolCall,

  // Handler
  async (req, res) => {
    const result = await invokeTool(req.body);
    res.json(result);
  }
]);

Monitoring Fetch Metadata violations for XS-Leak detection

Fetch Metadata rejections are security events. A single cross-site request to a tool endpoint might be a misconfigured integration. A burst of cross-site requests with varying Sec-Fetch-Dest values against authenticated endpoints is almost certainly a probe — either a security researcher or an attacker mapping your API surface.

// Structured violation logging for security monitoring
function resourceIsolationPolicyWithMetrics(req, res, next) {
  const site = req.headers['sec-fetch-site'];
  const mode = req.headers['sec-fetch-mode'];
  const dest = req.headers['sec-fetch-dest'];

  if (site === 'cross-site') {
    const violation = {
      timestamp: new Date().toISOString(),
      type: 'fetch_metadata_violation',
      path: req.path,
      method: req.method,
      sec_fetch_site: site,
      sec_fetch_mode: mode,
      sec_fetch_dest: dest,
      origin: req.headers['origin'] || null,
      referer: req.headers['referer'] || null,
      user_agent: req.headers['user-agent'],
      ip: req.ip,
    };

    // Emit to security monitoring (Datadog, Splunk, CloudWatch)
    securityEventEmitter.emit('fetch_metadata_violation', violation);

    // Rate-limit logging to avoid log flooding during probe storms
    if (shouldLog(req.ip)) {
      console.warn('Fetch Metadata violation:', JSON.stringify(violation));
    }

    return res.status(403).json({ error: 'Cross-site request rejected' });
  }

  next();
}

// Alert on probe storm: >10 violations from same IP in 60 seconds
const violationCounts = new Map();
function shouldLog(ip) {
  const now = Date.now();
  const entry = violationCounts.get(ip) || { count: 0, window_start: now };

  if (now - entry.window_start > 60_000) {
    entry.count = 0;
    entry.window_start = now;
  }

  entry.count++;
  violationCounts.set(ip, entry);

  if (entry.count === 10) {
    alertSecurityTeam(`Fetch Metadata probe storm from ${ip}: ${entry.count} violations in 60s`);
  }

  return entry.count <= 3; // Log first 3 per window to avoid log flooding
}

SkillAudit findings for Fetch Metadata

In SkillAudit's analysis of MCP server codebases, Fetch Metadata validation is one of the most commonly absent security controls — more than 80% of audited servers have no resource isolation policy despite being vulnerable to image-probe and iframe-based XS-Leak enumeration of their tool endpoints.

CRITICAL −24 No resource isolation policy on any tool endpoint — cross-site image, script, iframe, and no-cors fetch probes reach handler code, leaking HTTP status code differentials that enumerate private resource existence
CRITICAL −22 Fetch Metadata policy rejects requests with missing Sec-Fetch-* headers — breaks all SDK, CLI, mobile, and server-to-server callers; policy is security theater that cannot be applied uniformly
HIGH −20 No navigation injection defense — GET-method tool endpoints with side effects accept Sec-Fetch-Mode: navigate from Sec-Fetch-Site: cross-site, enabling one-click CSRF on state-mutating GET handlers
HIGH −18 WebSocket upgrade accepts cross-site connections — Sec-Fetch-Mode: websocket + Sec-Fetch-Site: cross-site allowed; attacker page opens authenticated WebSocket using victim's session cookies
HIGH −16 CSS import timing oracle not blocked — tool endpoints that return variable-length responses accessible as Sec-Fetch-Dest: style from cross-site pages leak response body size via CSS parse timing
MEDIUM −12 Fetch Metadata policy deployed as replacement for CSRF tokens — removes defense-in-depth for pre-Fetch-Metadata browser versions; policy and CSRF tokens should co-exist in a layered stack
MEDIUM −10 No Fetch Metadata violation logging — cross-site probe storms are invisible in security monitoring; no baseline for detecting XS-Leak enumeration campaigns against tool endpoints

Fetch Metadata resource isolation policy checklist

Run this check automatically: SkillAudit scans MCP server source code for resource isolation policy implementation across all tool endpoints — checking for missing Fetch Metadata validation, WebSocket upgrade origin enforcement, violation logging, and the correct non-browser client handling that avoids breaking SDK callers. Paste a GitHub URL to get a graded report in 60 seconds.