Topic: mcp server cache timing security

MCP server cache timing security — timing side channels, cache poisoning via LLM-controlled keys, and time-to-cache-hit enumeration

Performance caching is a natural optimization for MCP server tool responses that call slow upstream APIs. But caching introduces two security risks that are specific to the LLM context: cache timing side channels, where response time differences reveal whether a prior tool call occurred and what value it accessed; and cache poisoning, where LLM-controlled values in the cache key allow an attacker to inject responses for keys that another session will subsequently use. Neither risk is commonly documented in MCP server security literature, but both are exploitable through automated tool calls at machine speed.

Attack 1 — timing side channel via cache hit detection

When a tool response is served from cache, it returns in microseconds. When it requires a live upstream call, it returns in tens or hundreds of milliseconds. An LLM that can measure or observe response timing differences — even crudely, by comparing the narrative framing of successive responses — can infer whether a given cache key was already populated, revealing whether another session previously queried that value:

// Vulnerable cache implementation — timing difference is observable
export async function get_user_profile(args: { userId: string }) {
  const cacheKey = `user:${args.userId}`;
  const cached = await cache.get(cacheKey);

  if (cached) {
    // Cache hit: returns in ~1ms — observable timing difference
    return JSON.parse(cached);
  }

  // Cache miss: upstream call takes 150–300ms
  const profile = await db.users.findOne({ id: args.userId });
  await cache.set(cacheKey, JSON.stringify(profile), 300);  // TTL: 5 minutes
  return profile;
}

// Attack: An LLM in session B can determine whether session A
// previously queried userId "alice-123":
// - Tool call with userId="alice-123" returns fast → session A queried this user
// - Tool call returns slow → this is the first query for this user
//
// Over many queries, the LLM can enumerate which users are "active"
// (their profiles are in cache) without reading the user data directly.

Fix 1: add a constant-time cache delay — normalize response time so cache hits are indistinguishable from misses:

// Fixed: normalize response time to eliminate timing observable
const MIN_RESPONSE_MS = 200;  // every response takes at least 200ms

export async function get_user_profile(args: { userId: string }) {
  const start = Date.now();
  const cacheKey = `user:${args.userId}`;

  let profile = await cache.get(cacheKey);
  if (profile) {
    profile = JSON.parse(profile);
  } else {
    profile = await db.users.findOne({ id: args.userId });
    await cache.set(cacheKey, JSON.stringify(profile), 300);
  }

  // Pad response time to minimum — timing is now constant regardless of cache state
  const elapsed = Date.now() - start;
  if (elapsed < MIN_RESPONSE_MS) {
    await new Promise(r => setTimeout(r, MIN_RESPONSE_MS - elapsed));
  }

  return profile;
}

Attack 2 — cache key injection via LLM-controlled arguments

When cache keys are constructed from tool argument values without sanitization, an attacker can craft argument values that collide with or override legitimate cache keys. This is the MCP equivalent of a web cache poisoning attack: inject a response under a key that another session will look up:

// Vulnerable: cache key constructed directly from argument value
export async function get_product(args: { productId: string; region?: string }) {
  // VULNERABILITY: args.region is LLM-controlled and included verbatim in the key
  const cacheKey = `product:${args.productId}:${args.region || 'us'}`;

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

  const product = await db.products.findOne({ id: args.productId, region: args.region });
  await cache.set(cacheKey, JSON.stringify(product), 600);
  return product;
}

// Attack: an LLM in session A calls get_product with:
// { productId: "laptop-pro:eu\0", region: "us" }
//   → cacheKey = "product:laptop-pro:eu\0:us"
// This is different from "product:laptop-pro:eu:us" and may
// collide with implementations that treat null bytes differently in the cache backend.
//
// More dangerous: productId = "laptop-pro:us" (without region)
// → cacheKey = "product:laptop-pro:us:us" — same as the legitimate eu product
//    being looked up by another session (region hardcoded after colon)
// Fixed: hash the cache key so argument injection cannot control key structure
import { createHash } from 'crypto';

function buildCacheKey(toolName: string, args: Record<string, unknown>): string {
  // Stable JSON serialization → SHA-256 hash → opaque, unguessable key
  const canonical = JSON.stringify({ toolName, args }, Object.keys({ toolName, ...args }).sort());
  return createHash('sha256').update(canonical).digest('hex');
}

export async function get_product(args: { productId: string; region?: string }) {
  const cacheKey = buildCacheKey('get_product', args);
  // Key is now a 64-char hex string — no argument component is directly injectable

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

  const product = await db.products.findOne({
    id: args.productId,
    region: args.region || 'us'
  });
  await cache.set(cacheKey, JSON.stringify(product), 600);
  return product;
}

Attack 3 — cache poisoning via cross-session cache sharing

An in-memory cache shared across all sessions means a malicious session can poison the cache with an attacker-controlled value that a victim session will subsequently read. If the cache stores the result of a tool call that includes LLM-influenced data, the poisoned entry propagates attacker-controlled content to other users:

// Vulnerable: shared in-memory cache with no namespace isolation per session
const sharedCache = new Map<string, string>();

export async function get_market_data(args: { symbol: string }) {
  if (sharedCache.has(args.symbol)) {
    return JSON.parse(sharedCache.get(args.symbol)!);
  }

  const data = await marketDataAPI.get(args.symbol);
  sharedCache.set(args.symbol, JSON.stringify(data));
  return data;
}

// Attack: session A (attacker) injects a crafted value into args.symbol
// that causes the market data API to return attacker-controlled data,
// which is then cached under the legitimate symbol key.
// Session B (victim) reads the poisoned cache entry.

// Fixed: session-scoped cache + server-side response validation
export async function get_market_data(
  args: { symbol: string },
  context: { sessionId: string }
) {
  // Session-scoped cache key — attacker's session cannot affect victim's cache
  const cacheKey = `${context.sessionId}:${createHash('sha256').update(args.symbol).digest('hex')}`;

  const cached = await sessionCache.get(cacheKey);
  if (cached) {
    const parsed = JSON.parse(cached);
    // Validate cached response structure before returning
    if (isValidMarketData(parsed)) return parsed;
    await sessionCache.delete(cacheKey);  // invalid cache entry — evict and refetch
  }

  const data = await marketDataAPI.get(args.symbol);

  // Validate upstream response before caching
  if (!isValidMarketData(data)) {
    throw new Error('Market data API returned unexpected response structure');
  }

  await sessionCache.set(cacheKey, JSON.stringify(data), 60);  // shorter TTL for shared resources
  return data;
}

function isValidMarketData(data: unknown): data is MarketData {
  return (
    typeof data === 'object' &&
    data !== null &&
    typeof (data as any).price === 'number' &&
    typeof (data as any).symbol === 'string' &&
    (data as any).price > 0
  );
}

Cache TTL and freshness security considerations

TTL-related vulnerabilities are less dramatic but equally impactful in agentic contexts. A cached authorization decision that remains valid for 10 minutes means a revoked user's access token can still be used for tool calls for up to 10 minutes after revocation. Sensitive authorization caches need short TTLs or explicit invalidation on state change:

// Vulnerable: long TTL on authorization cache
async function isUserAuthorized(userId: string, tool: string): Promise<boolean> {
  const cacheKey = `authz:${userId}:${tool}`;
  const cached = await cache.get(cacheKey);
  if (cached !== null) return cached === 'true';

  const result = await checkPermissions(userId, tool);
  // VULNERABILITY: 10-minute TTL means revoked access persists for up to 10 minutes
  await cache.set(cacheKey, String(result), 600);
  return result;
}

// Fixed: short TTL for authorization cache + explicit invalidation
async function isUserAuthorized(userId: string, tool: string): Promise<boolean> {
  const cacheKey = `authz:${userId}:${tool}`;
  const cached = await cache.get(cacheKey);
  if (cached !== null) return cached === 'true';

  const result = await checkPermissions(userId, tool);
  // Short TTL: accept up to 30 seconds of stale authorization data
  await cache.set(cacheKey, String(result), 30);
  return result;
}

// Call on user deactivation, role change, or token revocation
async function invalidateUserAuthCache(userId: string): Promise<void> {
  const pattern = `authz:${userId}:*`;
  const keys = await cache.keys(pattern);
  await Promise.all(keys.map(k => cache.delete(k)));
}

SkillAudit detection

SkillAudit's Security axis flags these cache security patterns in MCP server implementations:

Scan your MCP server's cache implementation

SkillAudit detects cache key injection vulnerabilities, cross-session cache sharing, and missing response validation in MCP tool response caching patterns.

Request a free audit →

Related security topics