Security Guide

MCP server cache partitioning security — browser double-keyed cache, cross-tenant server-side cache poisoning, Vary header isolation, Redis cache key injection

Browser caches are now double-keyed by (top-level site, resource origin), which eliminated the cross-origin history timing oracle that plagued the web for years. But this browser-side fix leaves server-side caches entirely untouched. MCP servers that cache tool responses in Redis, Memcached, or a CDN without tenant-scoped cache keys are still vulnerable to cross-tenant cache poisoning — a much more direct data exfiltration risk than the browser timing oracle it replaced.

Browser cache is now double-keyed — the history timing oracle is dead

Before Chrome 86 (October 2020) and Firefox 85 (January 2021), all major browsers maintained a single-keyed HTTP cache: the cache key was simply the resource URL. A resource fetched from https://cdn.example.com/logo.png was stored once and reused regardless of which site triggered the request. This design had a serious security flaw: an attacker page could probe whether a user had previously visited a target site by timing a fetch() for a static resource hosted on that site.

The attack is straightforward. The attacker loads https://attacker.com and executes:

// Cross-origin cache timing oracle (pre-2020, no longer works)
async function probeCache(url) {
  const start = performance.now();
  await fetch(url, { mode: 'no-cors', cache: 'force-cache' });
  const elapsed = performance.now() - start;
  // <5ms → cached (user visited the target site before)
  // >80ms → uncached (user has not visited)
  return elapsed < 5;
}

const visited = await probeCache('https://bank.example.com/assets/logo.png');
// Leaks browsing history across origins — now patched by double-keying

The response time delta between a cache hit (~1ms) and a cache miss (~80–200ms depending on origin latency) was large enough to be a reliable oracle. Researchers demonstrated it could distinguish between thousands of URLs in a few seconds, building a near-complete browsing history from a single malicious page load.

Double-keyed caching eliminates this. The new cache key is a tuple: (top-level site, resource origin, resource URL). A fetch from https://attacker.com for https://bank.example.com/logo.png creates a separate cache entry from the same resource fetched while on https://bank.example.com. The attacker's probe will always see a cache miss — the cached entry exists only under the (bank.example.com, bank.example.com, logo.png) key, not the (attacker.com, bank.example.com, logo.png) key the attacker's probe uses.

Safari note: Safari has used a variant of partitioned caching since at least 2013 via Intelligent Tracking Prevention (ITP). Chrome's rollout in 2020 finally brought the major browser engines into alignment on this fundamental isolation boundary.

The performance cost of double-keying is a meaningful increase in cache miss rate for cross-origin subresources — a resource shared across CDN-delivered pages is no longer shared in the browser cache across top-level sites. CDN hit rates drop and bandwidth usage increases, but the security benefit justifies the trade-off.

What browser cache partitioning does NOT protect against

Double-keyed caching is a browser-level protection. It addresses exactly one attack: the cross-origin history oracle via cache timing. It does not protect against anything happening on the server side, and this is where MCP server deployments face real risk.

Specifically, browser cache partitioning provides no protection against:

Server-side shared caches. Redis, Memcached, Varnish, and CDN edge caches are entirely outside the browser's security model. When your MCP server fetches a tool result, checks a Redis cache, and returns the cached value, the browser's double-keying is irrelevant — the vulnerability lives in how the server constructed the Redis cache key before the browser was involved at all.

Same-site cache probing. Double-keying partitions by top-level site. If an attacker is already executing JavaScript on the same top-level site as the victim (a stored XSS, a compromised subdomain sharing the same eTLD+1, or a deliberately hosted attacker page on the same registrable domain), the double-keyed cache provides no partitioning — both parties share the same top-level site component of the cache key.

CDN cache poisoning via unkeyed headers. CDN caches use their own key derivation logic, not the browser's double-keying. A request header that influences the response but is absent from the CDN's cache key becomes an unkeyed input — a classic cache poisoning vector that browser partitioning does nothing to address.

The mental model shift: Browser cache partitioning is a client-side defense against a client-side timing attack. Every cache poisoning vulnerability in MCP servers involves server-side caches, where the client's browser double-keying is completely irrelevant. Do not conflate the two.

Server-side cache poisoning via cache key injection

MCP servers commonly cache tool responses to reduce latency and backend load. The typical pattern in Node.js looks like this:

// Vulnerable: cache key built from unsanitized user-controlled params
async function callTool(toolName, params, userId) {
  const cacheKey = `tool:${toolName}:${JSON.stringify(params)}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const result = await executeToolBackend(toolName, params, userId);
  await redis.set(cacheKey, JSON.stringify(result), 'EX', 300);
  return result;
}

// Attacker call:
// toolName = "search"
// params   = { "q": "normal query", "__proto__": "tool:admin:listSecrets" }
// Or more directly:
// params   = { "q": "...\"}tool:admin:listSecrets" }
// → cacheKey becomes something that collides with another user's cached result

The attack surface is wider than it appears. If params is serialized naively (as above), an attacker who controls any parameter value can attempt to craft a JSON serialization that, when concatenated into the key, produces a collision with another user's cache key. Worse, in a multi-tenant deployment where different tenants share the same Redis instance, the cache key may not include the tenant identifier at all:

// Vulnerable multi-tenant deployment — tenant not in cache key
const cacheKey = `tool:${toolName}:${paramHash}`;
// tenant-A's result for "search:abc123" is served to tenant-B
// if they make a request that produces the same paramHash

The correct pattern requires tenant isolation as the outermost key scope, and HMAC-based key derivation for any user-controlled input:

import crypto from 'crypto';

const CACHE_SECRET = process.env.CACHE_HMAC_SECRET; // 32+ random bytes

function buildCacheKey(tenantId, toolName, params) {
  // 1. Tenant is always the outermost scope
  // 2. User-controlled params are HMAC'd, not concatenated raw
  const paramCanonical = JSON.stringify(params, Object.keys(params).sort());
  const paramHmac = crypto
    .createHmac('sha256', CACHE_SECRET)
    .update(`${tenantId}:${toolName}:${paramCanonical}`)
    .digest('hex');
  return `sa:v1:${tenantId}:${toolName}:${paramHmac}`;
}

async function callTool(tenantId, toolName, params, userId) {
  const cacheKey = buildCacheKey(tenantId, toolName, params);

  const cached = await redis.get(cacheKey);
  if (cached) {
    // Validate the cached result belongs to this tenant before returning
    const parsed = JSON.parse(cached);
    if (parsed.tenantId !== tenantId) {
      await redis.del(cacheKey); // Evict corrupted entry
      throw new Error('Cache tenant mismatch — evicted');
    }
    return parsed.result;
  }

  const result = await executeToolBackend(toolName, params, userId);
  await redis.set(
    cacheKey,
    JSON.stringify({ tenantId, toolName, result }),
    'EX', 300
  );
  return result;
}

Critical: The HMAC collapses all user-controlled input into a fixed-length opaque token. No matter how long or structurally complex the attacker makes their parameter values, the resulting cache key is always 64 hex characters prefixed by the tenant scope. Key collision attacks and prefix injection attacks become computationally infeasible against a properly keyed HMAC.

CDN cache key normalization and header injection

CDN caches (Cloudflare, Fastly, AWS CloudFront) compute their own cache keys independently of browser logic. The default cache key for most CDNs is the full request URL. Request headers are not in the cache key by default — which means any request header that influences the backend response becomes an unkeyed input and a cache poisoning vector.

Consider an MCP server behind Cloudflare where the backend uses X-Forwarded-Host to construct absolute URLs in tool responses:

// Backend generates absolute URLs using X-Forwarded-Host
function buildToolResponse(toolResult, req) {
  const host = req.headers['x-forwarded-host'] || req.hostname;
  return {
    ...toolResult,
    // Attacker sets X-Forwarded-Host: attacker.com
    // This URL gets cached and served to legitimate users
    downloadUrl: `https://${host}/api/tool/download/${toolResult.id}`,
  };
}

An attacker sends a request with X-Forwarded-Host: attacker.com. If the CDN does not strip this header before passing the request to the origin, the response contains downloadUrl: "https://attacker.com/api/tool/download/...". The CDN caches this response under the URL key. Subsequent legitimate requests for the same URL receive the poisoned response and follow attacker-controlled download URLs.

The remediation for MCP servers behind a CDN is threefold:

Header Risk Remediation
X-Forwarded-Host Host header injection via CDN forwarding Strip at CDN ingress; use only the known-good origin hostname in backend logic
X-Original-URL URL override in some reverse proxy configurations Strip at CDN; backend should use request path from validated routing only
Authorization Different users receive different data for same URL Never cache authenticated responses at CDN; use Cache-Control: private, no-store
Accept-Language Localized responses cached for wrong locale Add to Vary header; or normalize to supported locales before caching

For authenticated MCP tool endpoints, the correct CDN configuration is unambiguous: every response from an authenticated endpoint must carry Cache-Control: private, no-store. This directive instructs both CDNs and intermediate proxies not to store the response. It must be set by the MCP server, not relied upon from CDN default configuration, because CDN defaults can be overridden and misconfigured.

Vary header for cache isolation

The Vary response header tells caches — both browser and intermediary — which request headers must match a stored response for that response to be considered a valid cache hit. Without Vary, all requests to the same URL are treated as equivalent by the cache regardless of how the request headers differ.

For content-negotiated MCP server responses (where the response format differs based on Accept or Accept-Encoding), the Vary header is essential:

// Correct Vary usage for a content-negotiated public endpoint
app.get('/api/tool/schema/:toolName', (req, res) => {
  const format = req.accepts(['application/json', 'application/yaml']);

  res.set('Vary', 'Accept, Accept-Encoding');
  res.set('Cache-Control', 'public, max-age=3600');

  if (format === 'application/yaml') {
    res.type('yaml').send(toYaml(schema));
  } else {
    res.type('json').json(schema);
  }
});

// WRONG: Vary on Authorization — dangerous on shared caches
// res.set('Vary', 'Authorization');
// A shared cache will store separate entries per Authorization value,
// but any cache that does not understand Vary may serve the wrong entry.
// Never cache authenticated responses in shared caches at all.

The critical rule: Vary: Authorization is not a safe substitute for Cache-Control: private, no-store on authenticated endpoints. While a correctly implemented shared cache will store separate entries per Authorization token, the behavior of non-conformant intermediary caches (and CDNs with custom Vary handling) is not guaranteed. The only safe behavior for authenticated tool responses is to prevent shared caching entirely.

Vary header omission on CDNs: Many CDN configurations strip or ignore Vary headers that reference non-standard headers. If your MCP server uses a custom X-Tenant-ID header to vary responses and your CDN ignores it, every tenant gets each other's cached responses. Always test CDN Vary behavior explicitly against your specific CDN configuration.

Redis cache key prefix injection

Redis keys are arbitrary byte strings — Redis imposes no structure on key names, and no key-space access control at the key level within a single Redis instance. An MCP server that constructs Redis cache keys from user input without sanitization is vulnerable to key-space collisions, key-space scanning confusion, and — in deployments using key-prefix-based access control layers — privilege escalation via crafted prefixes.

The naive anti-pattern:

// Vulnerable: raw user input in Redis key
async function getCachedToolResult(toolName, rawInput) {
  // rawInput could be: "foo\x00admintoken" or "foo:sa:admin:secret"
  // or a very long string causing key-space exhaustion
  const key = `cache:${toolName}:${rawInput}`;
  return await redis.get(key);
}

// A crafted rawInput like:
//   "normal\nSET cache:admin:privileged attacker_payload\r\n"
// could cause protocol injection in some Redis client configurations
// that pass raw strings to RESP encoding without sanitization.

Beyond RESP protocol injection (which modern Redis clients prevent by default), raw string keys create two practical problems in MCP server caching:

Key-space collision. An attacker who can guess or observe the cache key structure can craft a rawInput that, when concatenated, produces a key identical to another user's cache entry. Even without HMAC bypass, structural guessing is often feasible when keys follow a predictable format.

Key-space exhaustion (DoS). Without length limits on the input used in key construction, an attacker can fill Redis key-space by sending requests with unique, very long inputs. Each request generates a new, unique cache key that is never reused, defeating caching entirely and filling Redis memory.

import crypto from 'crypto';

const CACHE_SECRET = process.env.REDIS_CACHE_SECRET;
const MAX_INPUT_LENGTH = 4096; // Prevent key-space DoS

function safeRedisKey(prefix, ...inputs) {
  // Validate input lengths before hashing
  for (const input of inputs) {
    if (typeof input !== 'string' || input.length > MAX_INPUT_LENGTH) {
      throw new Error(`Cache key input too long or invalid type`);
    }
  }

  // HMAC all user-controlled inputs together
  const hmac = crypto.createHmac('sha256', CACHE_SECRET);
  for (const input of inputs) {
    hmac.update(input).update('\x00'); // Null separator prevents concatenation collisions
  }

  return `${prefix}:${hmac.digest('hex')}`;
}

// Usage — produces fixed-length, collision-resistant Redis key
const key = safeRedisKey('sa:tool', tenantId, toolName, userQuery);

Null-byte separators: When HMACing multiple inputs together, always use a separator (null byte or another delimiter that cannot appear in your inputs) between inputs. Without a separator, HMAC('ab', 'c') and HMAC('a', 'bc') produce the same digest — a length-extension-style ambiguity even with HMAC. With a null-byte separator, the two produce distinct digests.

SkillAudit findings for cache partitioning

The following findings represent real vulnerability patterns SkillAudit identifies when scanning MCP server repositories. Severity scores reflect the data exfiltration potential in a multi-tenant deployment.

CRITICAL -24 pts — Shared Redis cache without tenant isolation: tool results keyed by tool name and parameters only, with no tenant identifier in the cache key. Any tenant can retrieve another tenant's cached tool output by constructing a matching request.
CRITICAL -22 pts — CDN caches authenticated tool responses: Cache-Control: public set on endpoints that return user-specific or tenant-specific data, allowing the CDN to serve one user's tool output to another user who makes an identical request.
HIGH -20 pts — Cache key includes raw user-controlled parameters without HMAC: attacker can craft parameter values that collide with another user's cache key, causing cache poisoning or cross-user data leakage from the tool result cache.
HIGH -18 pts — Vary header missing on content-negotiated responses: CDN serves the same cached response regardless of the client's Accept header, causing some clients to receive responses in the wrong format or — when Vary should include a tenant header — the wrong tenant's data.
MEDIUM -12 pts — No cache key length limit: user-controlled inputs used in Redis key construction without length bounds, enabling cache key DoS by flooding Redis key-space with unique, never-reused long-key cache entries that exhaust available memory.
MEDIUM -10 pts — COEP credentialless effect on cached cross-origin resources not accounted for: COEP credentialless causes cross-origin resource fetches to omit cookies, resulting in cache entries that differ between credentialed and non-credentialed contexts; cached non-credentialed responses may be served in credentialed contexts without proper Vary or separate cache namespacing.

Audit your MCP server for these issues

SkillAudit checks these security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →