Topic: mcp server timing attack security

MCP server timing attack security — side-channel leaks in tool handlers

Timing side-channel attacks exploit the fact that code paths that handle different inputs take measurably different amounts of time. An attacker who can make repeated tool calls and observe response latency can extract secret information — whether a token is valid, whether a resource ID exists, whether a string matches — without triggering any authentication or authorization error.

String comparison timing in MCP authentication

An MCP server that validates an API key or HMAC token by comparing it to a stored value using JavaScript's === operator is vulnerable to timing-based token recovery:

// VULNERABLE — === short-circuits on the first differing byte
function validateToken(provided, expected) {
  return provided === expected;  // exits early on first mismatch
}

// Tool handler with timing-vulnerable auth check
server.tool('get_secrets', { token: z.string() }, async ({ token }) => {
  if (!validateToken(token, process.env.API_TOKEN)) {
    throw new Error('Unauthorized');
  }
  return { content: [{ type: 'text', text: await readSecrets() }] };
});

Because === on strings returns false at the first mismatched character, tokens that share a longer common prefix with the correct value take fractionally longer to reject. An attacker making thousands of requests can measure these differences — even through network jitter — to recover the token character by character. The attack requires on the order of N × alphabet_size requests for a token of length N, rather than the alphabet_size^N required for a brute-force search.

The correct defense is a constant-time comparison function that always examines every byte of both strings before returning, regardless of where the first difference occurs:

import { timingSafeEqual } from 'crypto';

function validateToken(provided, expected) {
  // Both buffers must be the same length; pad or hash first if needed
  const a = Buffer.from(provided, 'utf-8');
  const b = Buffer.from(expected, 'utf-8');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);  // constant time regardless of first differing byte
}

Node.js's built-in crypto.timingSafeEqual is implemented in C and is constant-time by design. Python's hmac.compare_digest provides the same guarantee. These functions must be used for any comparison of authentication tokens, HMAC signatures, session IDs, or other secrets where timing information could aid recovery.

Cache-timing oracle for resource existence

MCP servers that read files or records from a cache layer can leak whether a resource ID exists through the timing difference between a cache hit and a cache miss:

// VULNERABLE — cache hit is measurably faster than disk read
server.tool('get_document', { doc_id: z.string() }, async ({ doc_id }) => {
  const cached = cache.get(doc_id);
  if (cached) return { content: [{ type: 'text', text: cached }] };  // ~0.1ms

  const doc = await db.findDocument(doc_id);  // ~10ms on miss
  if (!doc || doc.owner !== currentUser) throw new Error('Not found');
  cache.set(doc_id, doc.content);
  return { content: [{ type: 'text', text: doc.content }] };
});

An attacker who cannot read documents owned by other users can still determine whether a given document ID exists by measuring response time: a cache hit (fast response, ~0.1ms) reveals that the document exists and was recently accessed; a miss + authorization failure (slow response, ~10ms + query time) also reveals the document's existence because the database lookup runs before the ownership check. Even if both paths return the same "Not found" error, the timing difference is a boolean oracle on resource existence.

The defenses depend on sensitivity:

Database query timing as an existence oracle

The same pattern applies to database queries. An MCP tool that queries for a resource and returns a generic "not found" error can still leak existence information if the query takes longer when the resource exists (due to index hits returning rows that are then filtered by ACL) versus when it does not (index miss, empty result set immediately):

// VULNERABLE — timing reveals whether user_id exists in the users table
server.tool('reset_password', { email: z.string() }, async ({ email }) => {
  const user = await db.query('SELECT id FROM users WHERE email = ?', [email]);
  // Timing: ~2ms if email exists (row found, proceed), ~0.5ms if not (no row)
  if (!user) {
    return { content: [{ type: 'text', text: 'If that email is registered, a reset link has been sent.' }] };
  }
  await sendPasswordReset(user.id);
  return { content: [{ type: 'text', text: 'If that email is registered, a reset link has been sent.' }] };
});

Despite identical response text, an attacker can enumerate valid email addresses by timing. The fix is to normalize the code path: always run the same operations regardless of whether the user exists, or add jitter to equalize the response time distribution.

What SkillAudit checks

SkillAudit's static analysis flags MCP server code that uses === or == for comparing authentication tokens, HMAC digests, or values derived from secrets. It also flags direct crypto.createHmac(...).digest() comparisons where the result is not passed to a constant-time comparison function. Timing oracle vulnerabilities in cache lookup and database query patterns require dynamic analysis or architectural review — SkillAudit notes servers that expose resource-lookup tools without evidence of jitter or authorization-before-lookup patterns as a permissions hygiene finding.

Audit your MCP server's authentication and lookup code for timing leaks.

Run a free audit →