MCP Server Security · Covert Channels

MCP server covert channel security — timing covert channels, storage covert channels, and cross-session information leakage in MCP servers

A covert channel is a communication path between processes or sessions that was not designed for information transfer, and that bypasses all access control checks. In MCP servers, covert channels arise from shared infrastructure — caches, connection pools, rate limit counters, and processing queues — that is visible across session boundaries. A malicious session can encode a query into observable behavior (latency, cache hit/miss) and a cooperating or victim session can read the answer, transmitting information without any data access violation that access control would prevent.

Covert channel taxonomy in MCP server deployments

Channel typeMechanismBandwidthMCP example
Timing channelEncode data in timing of operations (fast = cache hit / 0; slow = cache miss / 1)Low (bits/sec)Attacker measures latency of queryUser(id) calls to determine which user IDs are cached (i.e., recently accessed by another session)
Storage channelEncode data by setting/unsetting shared state (e.g., populate or evict cache entries)MediumAttacker fills cache entries for specific keys; victim session infers which keys are populated by measuring eviction behavior
Resource contention channelEncode data via CPU/memory/I/O contention patterns visible to other processesLow–mediumHigh-load tool calls in one session cause measurable latency increase in concurrent sessions on the same host
Error timing channelDifferent error paths execute different code → different timings reveal branching decisionsMediumAuthentication tool: userExists(email) returns faster for non-existent users (early return) than existing users (password hash comparison) — user enumeration via timing

Timing covert channel: cache-timing user enumeration

The most exploitable timing channel in MCP servers is cache-timing in tool calls that look up resources by identifier. When a cached lookup is faster than an uncached one, an attacker can determine whether a resource was recently accessed — revealing activity patterns from other sessions.

// Vulnerable: timing leak via cache hit/miss differential
async function queryUser(userId) {
  const cached = cache.get(`user:${userId}`);
  if (cached) return cached;       // Fast path: ~1ms
  const user = await db.query(     // Slow path: ~50ms database round-trip
    'SELECT * FROM users WHERE id = ?', [userId]
  );
  cache.set(`user:${userId}`, user, 300);
  return user;
}
// Attacker measures latency of queryUser(id) calls:
// ~1ms → user was accessed by another session in the last 5 minutes
// ~50ms → user not recently accessed

// Defense: constant-time cache with noise injection
async function queryUserConstantTime(userId) {
  const startTime = performance.now();
  const cached = cache.get(`user:${userId}`);
  let user;
  if (cached) {
    user = cached;
    // Simulate database latency so cache hits are not measurable
    const elapsed = performance.now() - startTime;
    const noise = Math.random() * 10; // 0–10ms noise
    await sleep(Math.max(0, 50 - elapsed + noise)); // pad to ~50ms
  } else {
    user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
    cache.set(`user:${userId}`, user, 300);
  }
  return user;
}

// For authentication: always run the full password hash comparison
// even when the user doesn't exist (use a dummy hash)
async function authenticate(email, password) {
  const DUMMY_HASH = '$argon2id$v=19$m=65536,t=3,p=4$dummyhashvalue$';
  const user = await getUserByEmail(email); // Always runs
  const hash = user ? user.passwordHash : DUMMY_HASH;
  const valid = await argon2.verify(hash, password); // Always runs full comparison
  if (!user || !valid) throw new Error('INVALID_CREDENTIALS');
  return user;
}

Storage covert channel: cache poisoning for cross-session signaling

A storage covert channel uses a shared mutable resource — a cache, a rate limit counter, a queue depth — as a communication medium. An attacker session writes a signal by populating specific cache entries; a cooperating session reads the signal by testing whether those entries are populated.

// Attack: attacker session signals "true" by populating key cache:user:TARGET_ID
// Victim session (or cooperating session) infers signal by measuring query latency
// or by explicitly probing cache-dependent behavior

// Defense 1: per-tenant cache namespacing — sessions can't share cache entries
function getCacheKey(tenantId, resource, id) {
  return `t:${tenantId}:${resource}:${id}`;
  // A session from tenant A cannot observe or influence entries for tenant B
}

// Defense 2: cache isolation via separate Redis databases or key prefixes enforced at middleware
class TenantScopedCache {
  constructor(redis, tenantId) {
    this.redis = redis;
    this.prefix = `tenant:${tenantId}:`;
  }
  get(key) { return this.redis.get(this.prefix + key); }
  set(key, value, ttl) { return this.redis.setex(this.prefix + key, ttl, JSON.stringify(value)); }
  del(key) { return this.redis.del(this.prefix + key); }
}

// Defense 3: for shared caches that cannot be tenant-scoped (e.g. CDN responses),
// add cryptographic nonces to cached content to prevent attacker from predicting cache keys
function makeCacheKey(resource, id, tenantId) {
  const mac = crypto.createHmac('sha256', process.env.CACHE_KEY_SECRET)
    .update(`${tenantId}:${resource}:${id}`)
    .digest('hex')
    .slice(0, 16);
  return `${resource}:${mac}`;
}

Resource contention channel: rate limit oracle

Rate limit counters are shared infrastructure. If your MCP server exposes rate limit state in error responses or in observable behavior, an attacker session can deplete another session's rate limit budget as a denial of service, or use the rate limit counter as a storage channel to signal other sessions.

// Vulnerable: rate limit counters shared across sessions by IP or user ID
// An attacker can deplete the target session's budget with their own requests
// if the rate limiter uses a shared namespace

// Defense: rate limit by session ID, not by IP or user ID alone
const sessionRateLimiter = rateLimit({
  keyGenerator: (req) => req.session.id, // Per-session, not per-IP
  windowMs: 60_000,
  max: 100,
  // Don't expose remaining count in headers — this is a storage channel
  standardHeaders: false,
  legacyHeaders: false,
});

// Avoid exposing rate limit state that can be used as an oracle:
// X-RateLimit-Remaining: N  ← this is a timing/storage channel if shared across sessions

Covert channels are hard to fully eliminate — any shared resource creates a potential channel. The goal is reducing bandwidth (how much information can leak per unit time) below a practical exploitation threshold, and ensuring cross-tenant leakage is architecturally prevented through namespacing and isolation even when timing channels remain.

SkillAudit findings for covert channel vulnerabilities in MCP servers

HIGH −16Authentication tool does not run constant-time comparison for non-existent users — user enumeration via timing (fast non-exist vs slow exists+hash) possible from tool call latency measurements
HIGH −14Cache keys are not tenant-scoped — a session can probe cache hit/miss for resource IDs belonging to other tenants, leaking access patterns and recently-active resource IDs
MEDIUM −12Rate limit counters shared by IP address across all sessions — a malicious session from the same IP can deplete another session's rate budget (DoS) or use the counter value as a storage channel
MEDIUM −10Response headers expose rate limit remaining count — this counter changes based on other sessions' behavior on shared IP, observable as a covert channel
MEDIUM −8Error messages differentiate "resource not found" from "resource found but access denied" — oracle leaks existence of resources the requester cannot access

SkillAudit's static analysis flags non-constant-time comparison paths and cross-tenant cache key patterns in MCP server code. Run a free audit to identify covert channel risks in your deployment.