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
Sec-Fetch-Site. Cross-site form submissions and fetch() attacks from attacker-controlled pages are not blocked at the HTTP layer. −15 ptsSec-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 ptsSec-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 ptsSee also
- MCP server CSRF security — full CSRF defense including SameSite cookies and token strategies
- MCP server CORS security — Access-Control headers and CORS preflight for MCP API layers
- MCP server Private Network Access security — PNA headers for localhost and private IP MCP servers
- MCP server header injection — preventing header injection in HTTP responses
- MCP server security checklist — comprehensive pre-submission checklist