Topic: mcp server side-channel security

MCP server side-channel security — timing attacks, cache-timing, response-size oracle

Side-channel attacks extract information from how a system behaves rather than from the data it returns directly. In MCP servers, three side-channel patterns appear most frequently: timing attacks on secret comparison (does the comparison take longer when more bytes match?), cache-timing on database lookups (does a cache hit return faster, revealing that a record exists?), and response-size as a data oracle (does a larger response body indicate more matching records?). None of these require exploiting a code vulnerability in the classic sense — they exploit measurement of observable behavior.

Pattern 1: timing attacks on secret comparison

JavaScript's === operator short-circuits on the first mismatched byte. This means that comparing a submitted API key against the stored key takes measurably less time when the first byte mismatches than when the first byte matches. An attacker who can make many requests and measure response latency can recover the secret one byte at a time by finding the candidate byte that produces the longest comparison time.

In an MCP server, this appears most often in: API key verification before tool execution, HMAC verification of webhook payloads, session token validation, and any other point where a caller-supplied value is compared against a stored secret.

const crypto = require('crypto');

// WRONG: === short-circuits on first mismatch — timing leak
function verifyApiKey_WRONG(submittedKey, storedKey) {
  return submittedKey === storedKey; // leaks how many bytes match
}

// WRONG: Buffer.compare also leaks
function verifyHmac_WRONG(payload, secret, receivedHmac) {
  const expected = crypto.createHmac('sha256', secret)
    .update(payload).digest('hex');
  return receivedHmac === expected; // timing leak
}

// RIGHT: crypto.timingSafeEqual — constant time regardless of match position
function verifyApiKey(submittedKey, storedKey) {
  // timingSafeEqual requires same-length buffers
  // If lengths differ, keys definitely don't match — but still do a constant-time compare
  // by comparing against a zero buffer of the expected length, then checking lengths separately
  const expected = Buffer.from(storedKey, 'utf8');
  const submitted = Buffer.from(submittedKey, 'utf8');

  if (expected.length !== submitted.length) {
    // Still call timingSafeEqual to avoid length-based timing leak
    // compare against dummy buffer of the submitted length
    crypto.timingSafeEqual(submitted, Buffer.alloc(submitted.length));
    return false;
  }
  return crypto.timingSafeEqual(expected, submitted);
}

// RIGHT: HMAC verification — always compute expected before comparing
function verifyHmac(payload, secret, receivedHmac) {
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'hex'))
    .update(payload).digest();
  const received = Buffer.from(receivedHmac, 'hex');

  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(expected, received);
}

Pattern 2: cache-timing leaks on existence checks

When an MCP server maintains an in-memory or Redis cache of records, a cache hit returns significantly faster than a cache miss (which requires a database round-trip). If a tool exposes a "check if X exists" operation, an attacker can distinguish cache-hit from cache-miss by measuring latency — revealing which items have recently been accessed, or (in set-membership checks) whether a specific item exists at all.

This matters most when the existence of a record is sensitive: does user X have account type Y? Has secret document Z been accessed recently? Is API key K currently active?

// WRONG: cache hit (fast) vs cache miss (slow) reveals whether record exists
async function checkPermission_WRONG(userId, resource) {
  const cacheKey = `perm:${userId}:${resource}`;
  const cached = await cache.get(cacheKey);
  if (cached !== null) {
    return cached === 'allowed'; // fast path — timing leak
  }
  // slow path: DB lookup
  const perm = await db.query(
    'SELECT allowed FROM permissions WHERE user=$1 AND resource=$2',
    [userId, resource]
  );
  await cache.set(cacheKey, perm.allowed ? 'allowed' : 'denied', 3600);
  return perm.allowed;
}

// RIGHT: add synthetic delay on fast path to normalize latency
const MIN_CHECK_MS = 50; // must be >= P99 of DB lookup latency

async function checkPermission(userId, resource) {
  const start = Date.now();

  const cacheKey = `perm:${userId}:${resource}`;
  let result;

  const cached = await cache.get(cacheKey);
  if (cached !== null) {
    result = cached === 'allowed';
  } else {
    const perm = await db.query(
      'SELECT allowed FROM permissions WHERE user=$1 AND resource=$2',
      [userId, resource]
    );
    result = perm.rows.length > 0 && perm.rows[0].allowed;
    await cache.set(cacheKey, result ? 'allowed' : 'denied', 3600);
  }

  // Pad to MIN_CHECK_MS regardless of code path
  const elapsed = Date.now() - start;
  if (elapsed < MIN_CHECK_MS) {
    await new Promise(r => setTimeout(r, MIN_CHECK_MS - elapsed));
  }

  return result;
}

// ALTERNATIVE: for existence checks where the answer is sensitive,
// return the same response for both "not found" and "not allowed"
// so that even the response content doesn't reveal existence
async function getSecretDocument(userId, docId) {
  const doc = await db.query(
    'SELECT content FROM documents WHERE id=$1 AND owner=$2',
    [docId, userId]
  );
  // Same error for not-found and not-authorized — no enumeration
  if (!doc.rows.length) {
    return { error: 'Document not found or access denied' };
  }
  return { content: doc.rows[0].content };
}

Pattern 3: response-size as a data oracle

When an MCP tool returns a list of matching records, the size of the response body reveals how many records matched — which may be sensitive. For example, a search_audit_history tool that returns all audits matching a query: if a user can determine that searching for a specific GitHub URL returns 0 results vs 3 results, they can enumerate which URLs have been audited without seeing the audit content. Similarly, a response containing 500 bytes vs 5000 bytes reveals record counts even if the content is encrypted or access-controlled.

// WRONG: response size leaks record count
async function searchAudits_WRONG(userId, query) {
  const rows = await db.query(
    'SELECT * FROM audits WHERE owner=$1 AND query ILIKE $2',
    [userId, `%${query}%`]
  );
  // Response body size = rows.length * ~avg_record_size — count oracle
  return { results: rows.rows };
}

// OPTION 1: return a fixed summary instead of raw records
// (count is visible but content is not — decide if count is sensitive)
async function searchAudits_SummaryOnly(userId, query) {
  const rows = await db.query(
    'SELECT id, repo, grade, scanned_at FROM audits WHERE owner=$1 AND query ILIKE $2',
    [userId, `%${query}%`]
  );
  // Only return the subset of fields needed — smaller and more uniform
  return {
    results: rows.rows.map(r => ({
      id: r.id,
      repo: r.repo,
      grade: r.grade,
      scanned_at: r.scanned_at
    }))
  };
}

// OPTION 2: if count itself is sensitive, return a boolean + paginated cursor
// Never return more than PAGE_SIZE records; pad short pages to PAGE_SIZE with nulls
const PAGE_SIZE = 20;
async function searchAudits(userId, query, cursor) {
  const rows = await db.query(
    `SELECT id, repo, grade, scanned_at FROM audits
     WHERE owner=$1 AND query ILIKE $2
     ORDER BY scanned_at DESC LIMIT $3 OFFSET $4`,
    [userId, `%${query}%`, PAGE_SIZE + 1, cursor || 0]
  );

  const hasMore = rows.rows.length > PAGE_SIZE;
  const results = rows.rows.slice(0, PAGE_SIZE);

  // Pad to PAGE_SIZE so response size doesn't leak count on last page
  while (results.length < PAGE_SIZE) {
    results.push(null); // null entries are stripped by client but normalize size
  }

  return { results, hasMore, nextCursor: hasMore ? (cursor || 0) + PAGE_SIZE : null };
}

SkillAudit detection

Side-channel vulnerabilities are not detectable by pure static analysis — they require understanding the data flow from a timing or size measurement perspective. SkillAudit's Security axis flags two specific patterns:

  1. String equality operators (===, ==, Buffer.compare) applied to variables whose names contain key, token, secret, hmac, hash, or signature — these are high-probability timing attack surfaces.
  2. Tool handlers that return arrays or objects from database queries without any pagination or count-limiting — these are potential response-size oracle surfaces when the query includes user-controlled parameters.

The LLM-assisted prompt-injection probe in the Security axis also checks whether tool descriptions or parameter names hint at existence-check semantics without a corresponding "same error for both" pattern in the code.

→ MCP server weak cryptography — MD5, ECB, short keys, weak KDF
→ Input validation patterns for MCP server tool parameters
→ MCP security for open-source maintainers: what reviewers check in 2026