MCP server security · Fetch Metadata headers · CSRF defense · resource isolation

MCP server Fetch Metadata security — Sec-Fetch-Site, CSRF defense, and resource isolation policy

Fetch Metadata request headers — Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, and Sec-Fetch-User — are sent by modern browsers with every HTTP request and describe exactly where the request came from and why. MCP servers that validate these headers can reject cross-site CSRF attacks, navigation-based forgery, and cross-origin information leakage at the HTTP layer — before authentication is even checked — without relying on CSRF tokens for browsers that support the headers. MCP servers that ignore these headers miss an entire class of defense-in-depth controls.

The four Fetch Metadata headers

Fetch Metadata headers are added by the browser and cannot be set by JavaScript (they are Forbidden Header Names in the Fetch specification). This makes them trustworthy signals — an attacker-controlled page cannot forge them to impersonate a same-site request. They are available in Chrome 76+, Firefox 90+, and Safari 16.4+.

Header Values What it tells the MCP server
Sec-Fetch-Site same-origin, same-site, cross-site, none Whether the request originated from the same origin, same site (same registrable domain), a different site, or a direct navigation (no referrer)
Sec-Fetch-Mode navigate, cors, no-cors, same-origin, websocket The fetch mode — navigate is a top-level navigation (address bar, link click, redirect); cors is an explicit CORS fetch
Sec-Fetch-Dest document, script, image, fetch, empty, etc. The intended destination of the response — document for navigations, empty for fetch/XHR API calls
Sec-Fetch-User ?1 (present) or absent Only present when the navigation was triggered by explicit user interaction (click, keyboard). Absent for programmatic navigations.

Attack scenario: CSRF on MCP server state-changing endpoint

Without Fetch Metadata validation, a cross-site page can trigger state changes on an MCP server's HTTP endpoints if the user's session cookie is in scope. The browser sends the request with the session cookie attached — standard CSRF. Fetch Metadata headers let the server detect this at the HTTP layer.

// Attacker's page (https://attacker.example):
// The user is logged into skillaudit.dev.
// The attacker page submits a form that POSTs to the MCP server's tool-execute endpoint.

<form method="POST" action="https://mcp.skillaudit.dev/api/tool/execute">
  <input name="tool" value="delete_all_data">
  <input name="confirm" value="true">
</form>
<script>document.forms[0].submit()</script>

// The browser sends this request with:
// Cookie: session=user-session-token-here
// Sec-Fetch-Site: cross-site          ← from attacker.example to mcp.skillaudit.dev
// Sec-Fetch-Mode: navigate            ← form submission = navigate mode
// Sec-Fetch-Dest: document            ← expects a document in response
// Sec-Fetch-User: ?1                  ← triggered by script auto-submit (absent in some browsers)
//                                        OR ?1 if form was manually submitted

// MCP server WITHOUT Fetch Metadata validation:
// Sees the valid session cookie, processes the request, deletes all data. ← CSRF success

// MCP server WITH Fetch Metadata validation:
// Sees Sec-Fetch-Site: cross-site on a POST to a non-navigable API endpoint.
// Returns 403 Forbidden. ← CSRF blocked

Resource Isolation Policy implementation for MCP servers

The Resource Isolation Policy is a set of rules derived from Fetch Metadata headers that block requests which a legitimate browser would not send. The core rule: reject requests where Sec-Fetch-Site is cross-site unless the endpoint explicitly supports cross-origin access (such as a public API or a CORS-enabled endpoint).

// Resource Isolation Policy middleware for Express MCP server
// Based on the fetch-metadata npm package pattern and Google Security Engineering guidance

function resourceIsolationPolicy(options = {}) {
  // Endpoints that intentionally accept cross-origin requests (public API, OAuth redirect, etc.)
  const allowedCrossOriginPaths = options.allowedPaths || []

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

    // Pass 1: Browser doesn't support Fetch Metadata (pre-2020 browsers).
    // Fall through to CSRF token validation (handled separately).
    if (!site) {
      return next()
    }

    // Pass 2: Same-origin and same-site requests are always allowed.
    // Also allow requests with Sec-Fetch-Site: none (direct navigation, bookmarks).
    if (site === 'same-origin' || site === 'same-site' || site === 'none') {
      return next()
    }

    // At this point: Sec-Fetch-Site is 'cross-site'.
    // This request is coming from a different registrable domain.

    // Pass 3: Allow safe navigations to document endpoints (GET requests returning HTML pages).
    // A user clicking a link from another site to load the MCP server's UI is fine.
    if (mode === 'navigate' && dest === 'document' && req.method === 'GET') {
      // Only allow navigation to explicitly navigable paths
      const navigablePaths = options.navigablePaths || ['/app', '/login', '/oauth/callback']
      if (navigablePaths.some(p => req.path.startsWith(p))) {
        return next()
      }
    }

    // Pass 4: Explicitly allowed cross-origin API endpoints (e.g., CORS-enabled public API).
    if (allowedCrossOriginPaths.some(p => req.path.startsWith(p))) {
      return next()
    }

    // Block: cross-site request to a non-navigable or non-allowlisted endpoint.
    // This blocks:
    // - CSRF form submissions (Sec-Fetch-Mode: navigate to API endpoints)
    // - Cross-origin fetch() attacks (Sec-Fetch-Mode: cors/no-cors to state-changing endpoints)
    // - Cross-origin image/script tag loads that trigger state changes
    console.warn('Resource Isolation Policy: blocked cross-site request', {
      path: req.path,
      method: req.method,
      site,
      mode,
      dest,
      ip: req.ip,
    })
    return res.status(403).json({
      error: 'Forbidden',
      detail: 'Cross-site request rejected by resource isolation policy',
    })
  }
}

// Apply to all routes
app.use(resourceIsolationPolicy({
  navigablePaths: ['/app', '/login', '/oauth/callback', '/invite'],
  allowedPaths: ['/api/public', '/api/webhook'],  // CORS-enabled endpoints
}))

Sec-Fetch-Mode: navigate — blocking navigation-based CSRF

Classic CSRF using a <form> tag creates a navigate-mode request. The browser sets Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document. An MCP server's JSON API endpoint should never receive a navigate-mode request — it is not a navigable resource. Rejecting navigate requests to API endpoints is a high-signal CSRF indicator.

// Middleware specifically for JSON API endpoints — reject any navigate-mode request.
// JSON APIs are not navigable resources. No legitimate browser navigation reaches them.

function rejectNavigateToApi(req, res, next) {
  const mode = req.headers['sec-fetch-mode']
  const dest = req.headers['sec-fetch-dest']

  if (mode === 'navigate') {
    // A navigate-mode request to a JSON API endpoint is suspicious.
    // Legitimate API callers (MCP clients, fetch(), XHR) use mode: cors or same-origin.
    // Only CSRF form submissions and cross-site navigations use mode: navigate.
    console.warn('Suspicious navigate request to API endpoint', {
      path: req.path, dest, ip: req.ip,
    })
    return res.status(403).json({ error: 'Navigate requests not accepted by this API' })
  }

  // Similarly, reject if the destination suggests a resource type mismatch:
  // A JSON API should receive dest: empty (fetch/XHR) or dest: same-origin.
  // Receiving dest: document, dest: script, dest: image on an API path is anomalous.
  const apiAnomalousDests = ['document', 'script', 'style', 'image', 'font', 'worker']
  if (apiAnomalousDests.includes(dest)) {
    console.warn('Sec-Fetch-Dest mismatch for API endpoint', {
      path: req.path, mode, dest, ip: req.ip,
    })
    return res.status(403).json({ error: 'Request destination mismatch for API endpoint' })
  }

  next()
}

// Apply only to API routes
app.use('/api/', rejectNavigateToApi)

Sec-Fetch-User: ?1 — user-activation signal

Sec-Fetch-User: ?1 is added only when a navigation is triggered by explicit user interaction — a click, a key press, or a form submit event initiated by the user. Programmatic navigations (JavaScript location.href assignment, form.submit() in a script) do not carry it. For MCP server endpoints that handle sensitive user-initiated actions, requiring Sec-Fetch-User: ?1 adds an additional signal that the request is user-activated.

// Optional: require user activation signal for high-sensitivity navigable actions
// e.g., account deletion, payment confirmation, data export

function requireUserActivation(req, res, next) {
  const site = req.headers['sec-fetch-site']
  const user = req.headers['sec-fetch-user']
  const mode = req.headers['sec-fetch-mode']

  // Only apply to navigate-mode requests from same-origin (the app's own UI).
  // Cross-site navigates should already be blocked by the resource isolation policy.
  if (mode === 'navigate' && site === 'same-origin') {
    if (user !== '?1') {
      // Navigate request from our own origin without user activation — suspicious.
      // Could be a programmatic redirect from a compromised script in the page.
      console.warn('High-sensitivity action without user activation signal', {
        path: req.path, mode, site,
      })
      // Log for monitoring but don't block by default — Sec-Fetch-User has limited
      // browser support and some legitimate navigations may not include it.
      // In high-security contexts, return 403 here.
    }
  }
  next()
}

app.use('/account/delete', requireUserActivation)
app.use('/data/export', requireUserActivation)

Fallback: CSRF token for pre-Fetch-Metadata browsers

Fetch Metadata headers are sent by Chrome 76+, Firefox 90+, and Safari 16.4+. Older browsers do not send them. For state-changing endpoints on MCP servers that need to support pre-2020 browsers, Fetch Metadata validation must be combined with CSRF token validation as a fallback. The two defenses are complementary.

// Combined Fetch Metadata + CSRF token defense for maximum compatibility
import crypto from 'crypto'

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

  // If Fetch Metadata headers are present, use the Resource Isolation Policy.
  if (site) {
    if (site === 'same-origin' || site === 'same-site' || site === 'none') {
      return next()  // Trusted by Fetch Metadata
    }
    // cross-site — block (resource isolation policy would handle this earlier)
    return res.status(403).json({ error: 'Cross-site request blocked' })
  }

  // No Sec-Fetch-Site header — browser predates Fetch Metadata support.
  // Fall back to CSRF token validation.
  const sessionToken = req.session?.csrfToken
  const requestToken = req.headers['x-csrf-token'] || req.body?._csrf

  if (!sessionToken || !requestToken) {
    return res.status(403).json({ error: 'CSRF token required' })
  }

  // Constant-time comparison to prevent timing attacks
  const sessionBuf = Buffer.from(sessionToken, 'hex')
  const requestBuf = Buffer.from(requestToken, 'hex')

  if (sessionBuf.length !== requestBuf.length ||
      !crypto.timingSafeEqual(sessionBuf, requestBuf)) {
    return res.status(403).json({ error: 'CSRF token mismatch' })
  }

  next()
}

// Apply to all state-changing endpoints
app.post('/api/', combinedCsrfDefense)
app.put('/api/', combinedCsrfDefense)
app.delete('/api/', combinedCsrfDefense)
app.patch('/api/', combinedCsrfDefense)

MCP client architecture note: MCP clients that connect to MCP servers via the HTTP+SSE transport are not browser pages — they are Node.js or Python processes. They do not send Fetch Metadata headers. Fetch Metadata validation should only be applied to browser-facing HTTP endpoints (admin UI, OAuth callbacks, webhooks), not to the MCP protocol transport endpoints that MCP clients connect to.

SkillAudit findings

High
No Fetch Metadata validation on state-changing API endpoints — MCP server HTTP endpoints that modify state (tool execution, configuration changes, data deletion) do not check Sec-Fetch-Site. Cross-site form submissions and fetch() attacks from attacker-controlled pages are not blocked at the HTTP layer. −15 pts
High
JSON API endpoint accepts navigate-mode requests (Sec-Fetch-Mode: navigate) — The MCP server's JSON API endpoint responds successfully to Sec-Fetch-Mode: navigate requests. No legitimate API client sends navigate-mode requests to a JSON API — only CSRF form submissions do. −14 pts
Medium
Fetch Metadata validation without CSRF token fallback — The server validates Fetch Metadata headers but provides no fallback for browsers that predate Fetch Metadata support. Pre-2020 browsers (significant share in enterprise contexts) can still be used to CSRF the server. −10 pts
Medium
Missing Sec-Fetch-Dest mismatch detection on API routes — API endpoints do not verify that Sec-Fetch-Dest is empty (the value sent by fetch/XHR). Requests with Sec-Fetch-Dest: document or script to a JSON API indicate a resource-type mismatch that signals a cross-site attack. −8 pts
Low
Fetch Metadata violations not logged — Cross-site requests are rejected but rejection events are not logged to the security audit log. Blocked CSRF attempts are undetectable for incident response and anomaly detection. −4 pts

Run an audit →

See also