Topic: MCP server web cache deception security
MCP server web cache deception — poisoning authenticated tool responses
Web cache deception (WCD) is an attack where the adversary tricks a cache layer — a CDN, a reverse proxy like Nginx or Varnish, or an application-level cache — into storing an authenticated response and then serving it to unauthenticated requesters. MCP servers deployed behind caching infrastructure are vulnerable when the cache makes cacheability decisions based on URL path extensions or patterns, while the application makes access control decisions based on session tokens or cookies. The mismatch between the two creates a bypass window.
The attack mechanism
A typical MCP HTTP deployment sits behind Nginx or a CDN with a caching rule like "cache any response where the URL ends in a static extension (.js, .css, .png, .json)." The MCP server itself ignores URL extensions and routes all requests to /tools/call:
- The victim is logged in and has a valid session cookie
- The attacker sends the victim a link:
https://mcp.corp.internal/tools/call/get_documents.json - The victim (or their MCP client, via a link preview) requests this URL — with their session cookie
- The MCP server ignores the
.jsonsuffix, processes the request as/tools/call, and returns the victim's authenticated tool response - The CDN/proxy sees the
.jsonextension, classifies the response as static, caches it - The attacker then requests
https://mcp.corp.internal/tools/call/get_documents.jsonwithout any credentials — the cache serves the victim's response
# Step 2: attacker-controlled URL that bypasses cache keying
# CDN configured to cache *.json, *.js, *.css
# Step 3-4: victim's browser (or MCP client) fetches this URL
GET /tools/call/get_documents.json HTTP/1.1
Host: mcp.corp.internal
Cookie: mcp_session=victim_session_token
# Step 5: CDN sees *.json extension, caches the 200 response
# Response contains get_documents output — victim's private documents
HTTP/1.1 200 OK
Cache-Control: max-age=3600 # ← CDN overrides this with its own caching policy
Content-Type: application/json
{"documents": ["confidential-q2-plan.md", "personal-api-keys.md", ...]}
The attacker retrieves the cached response without the session cookie — the cache serves it because the cache key is the URL path (/tools/call/get_documents.json) without the cookie.
Why this affects MCP specifically
MCP servers are often designed to return JSON responses — exactly the type that caches are configured to cache. Many MCP server setups also add a CDN or reverse proxy for TLS termination and rate limiting, without thinking through the interaction between URL-based caching rules and session-based authentication.
Defense 1: Cache-Control headers on all authenticated responses
Every response from an authenticated endpoint must set explicit no-store headers. This prevents compliant caches from storing the response regardless of URL pattern:
// Express middleware: prevent caching on all authenticated routes
function noCache(req, res, next) {
res.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
'Pragma': 'no-cache',
'Surrogate-Control': 'no-store', // Varnish/CDN-specific
'CDN-Cache-Control': 'no-store', // Fastly/Cloudflare
});
next();
}
// Apply to all tool endpoints
app.use('/tools/', noCache);
app.use('/resources/', noCache);
app.use('/prompts/', noCache);
Note that CDN-level caches can be configured to ignore Cache-Control: no-store from the origin — always check whether your CDN configuration respects origin cache directives.
Defense 2: Vary on Cookie and Authorization
For responses that may legitimately be cached (e.g., tool list, server metadata), use the Vary header to include the session identifier in the cache key:
// For routes where caching is intentional but session-specific
function varyOnSession(req, res, next) {
res.set('Vary', 'Cookie, Authorization');
// Now the cache key includes the session cookie — different users get different cache entries
// This prevents the cross-user deception, but still allows per-user caching
next();
}
app.get('/tools/list', authenticate, varyOnSession, toolListHandler);
Vary: Cookie causes each unique cookie value to have its own cache entry. An unauthenticated attacker with no session cookie gets a cache miss (no entry for "no cookie"), and the server returns a 401 — which the cache then stores as the response for "no cookie" requests, preventing future exposure.
Defense 3: Nginx cache configuration exclusions
At the reverse proxy level, explicitly exclude MCP endpoint paths from caching:
# nginx.conf — exclude all MCP protocol endpoints from caching
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mcp_cache:10m;
location / {
proxy_pass http://mcp_backend;
proxy_cache mcp_cache;
proxy_cache_valid 200 1h;
}
# Override: never cache MCP protocol endpoints
location ~ ^/(tools|resources|prompts|roots|sampling)/ {
proxy_pass http://mcp_backend;
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "no-store, private" always;
}
location = /tools/call {
proxy_pass http://mcp_backend;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
Defense 4: Reject URLs with unexpected path components
The MCP server can defensively reject requests where the path does not exactly match the expected endpoint patterns — closing the "suffix appending" attack path:
// Reject requests that don't match exact MCP endpoint paths
const ALLOWED_PATHS = new Set([
'/tools/list',
'/tools/call',
'/resources/list',
'/resources/read',
'/prompts/list',
'/prompts/get',
'/roots/list',
'/ping',
]);
app.use((req, res, next) => {
const basePath = req.path.split('?')[0];
if (!ALLOWED_PATHS.has(basePath)) {
// Unknown path — could be cache deception attempt
return res.status(404).json({ error: 'Not found' });
}
next();
});
SkillAudit checks MCP server configurations for the absence of Cache-Control: no-store on authenticated endpoints and for Nginx/Caddy configurations that may cache MCP protocol paths. Absent caching headers on tool-call endpoints is a High-severity finding.
Check whether your MCP server's caching configuration leaks authenticated responses.
Run a free audit →