← Blog

MCP server resource subscription security

The MCP 2025-03-26 specification introduced resources/subscribe and resources/unsubscribe as first-class methods. Subscribed clients receive notifications/resources/updated and notifications/resources/list_changed push messages whenever a resource changes. The push model introduces four new attack surfaces: predictable subscription confirmations enable cross-session notification hijacking; confirmation delays create TOCTOU windows; missing rate limits enable subscription flood exhaustion; and missing access checks allow subscribing to resources the session cannot read. Four SA-MCP-RSB patterns document the complete resource subscription security surface.

By SkillAudit  |  31 Oct 2026

MCP resource subscriptions work as follows: the client sends a resources/subscribe request with a uri parameter identifying the resource to watch. The server responds with a success result and subsequently sends notifications/resources/updated notifications to the subscribed session whenever the resource changes. The client calls resources/unsubscribe to cancel. The server is responsible for tracking which sessions are subscribed to which resources and routing notifications correctly. Failures in this tracking logic are the primary source of SA-MCP-RSB vulnerabilities.

Community MCP server implementations commonly use in-memory subscription maps keyed by session ID. When session IDs are predictable, or when the subscription map does not bind subscriptions to the originating session, notification routing errors occur. The four SA-MCP-RSB patterns cover the most common failure modes observed in community MCP server implementations.

SA-MCP-RSB-001: predictable subscription token allows cross-session notification hijacking

When a server issues subscription confirmation tokens (used to identify a specific subscription in resources/unsubscribe requests) using predictable values such as sequential integers, timestamps, or resource URI hashes, an attacker who can observe or enumerate these tokens can unsubscribe another session’s resource subscription or, if the server routes notifications by subscription token rather than by session, intercept those notifications in their own session.

// VULNERABLE: sequential integer subscription IDs — trivially enumerable
let nextSubId = 1;
const subscriptions = new Map(); // subscriptionId → { sessionId, uri }

server.setRequestHandler('resources/subscribe', async (req, context) => {
  const subId = nextSubId++;  // predictable: 1, 2, 3 ...
  subscriptions.set(subId, { sessionId: context.sessionId, uri: req.params.uri });
  return { subscriptionId: subId };
  // Attacker calls resources/unsubscribe with subscriptionId 1..N → cancels all subs
  // If notifications are routed by subId without session check, attacker receives them
});

// VULNERABLE: resource URI hash as subscription token
server.setRequestHandler('resources/subscribe', async (req, context) => {
  const subId = hashResourceUri(req.params.uri);  // deterministic from public URI
  subscriptions.set(subId, { sessionId: context.sessionId, uri: req.params.uri });
  return { subscriptionId: subId };
  // Any client that knows the resource URI can compute the subscriptionId
  // and unsubscribe or impersonate the subscription
});

// SECURE: cryptographically random subscription token, bound to session
import { randomBytes } from 'node:crypto';
server.setRequestHandler('resources/subscribe', async (req, context) => {
  const subId = randomBytes(16).toString('hex');  // 128-bit unguessable token
  subscriptions.set(subId, {
    sessionId: context.sessionId,  // bound to originating session
    uri: req.params.uri
  });
  return { subscriptionId: subId };
});

// SECURE: always validate subscription ownership on unsubscribe
server.setRequestHandler('resources/unsubscribe', async (req, context) => {
  const sub = subscriptions.get(req.params.subscriptionId);
  if (!sub || sub.sessionId !== context.sessionId) {
    throw new Error('Subscription not found or not owned by this session');
  }
  subscriptions.delete(req.params.subscriptionId);
  return {};
});

// DETECTION (SkillAudit static analysis):
// Find subscription ID generation patterns
const weakSubIdPatterns = [
  /nextSub(Id|Count)\+\+/,        // sequential integer
  /Date\.now\(\)/,                 // timestamp-derived
  /hash(ResourceUri|Uri|Path)/i,  // deterministic hash of URI
  /subId\s*=\s*\d+/               // literal integer
];
// Any match → SA-MCP-RSB-001 CRITICAL
// Pass: randomBytes(n) where n >= 16

Subscription token predictability is a notification interception path: If the server routes notifications/resources/updated using the subscription token as the routing key (rather than the session), an attacker who can predict the token for a victim’s subscription will receive the victim’s resource change notifications in their own session. This leaks the content of every resource update the victim’s client would have received.

CRITICAL
SA-MCP-RSB-001 — subscription tokens generated with sequential integers, timestamps, or resource URI hashes: attacker enumerates tokens to cancel other sessions’ subscriptions or intercept their notifications. Detection: non-crypto subscription ID generation → CRITICAL.

SA-MCP-RSB-002: subscription confirmation delay creates TOCTOU window

MCP clients typically call resources/read immediately after resources/subscribe to get the initial resource state, then rely on notifications/resources/updated for subsequent changes. If the server processes the subscription asynchronously — for example, registering it with a message broker after returning the subscribe response — there is a window between the subscribe response and the actual subscription being active. Resource changes during this window are not delivered, creating a time-of-check time-of-use (TOCTOU) gap.

// VULNERABLE: async subscription registration after synchronous response
server.setRequestHandler('resources/subscribe', async (req, context) => {
  const subId = randomBytes(16).toString('hex');
  // Return immediately — subscription not yet active
  const response = { subscriptionId: subId };

  // Register with broker asynchronously (fire-and-forget)
  messageBroker.subscribe(req.params.uri, subId).catch(console.error);
  // Gap: changes between now and broker.subscribe completing are lost

  return response;
  // Client reads initial state THEN expects notifications to cover the delta.
  // Changes during the broker registration window are silently dropped.
});

// ALSO VULNERABLE: subscribe then read pattern without ordering guarantee
async function subscribeAndRead(uri) {
  await client.request({ method: 'resources/subscribe', params: { uri } });
  // No guarantee that subscription is active before the read completes
  const content = await client.request({ method: 'resources/read', params: { uri } });
  // Changes between subscribe response and read completion may be missed
}

// SECURE: register subscription before returning response
server.setRequestHandler('resources/subscribe', async (req, context) => {
  const subId = randomBytes(16).toString('hex');
  // Await subscription registration — subscription active before response sent
  await messageBroker.subscribe(req.params.uri, subId);
  return { subscriptionId: subId };
  // Now: client reads initial state AFTER subscription is confirmed active
  // No changes can be missed between subscribe response and first read
});

// DETECTION (SkillAudit):
// Flag: messageBroker.subscribe() / pubsub.subscribe() called without await
// Flag: subscription store write after response return (fire-and-forget pattern)
const subHandler = findRequestHandler('resources/subscribe');
if (hasFireAndForgetSubscription(subHandler)) → HIGH // SA-MCP-RSB-002

TOCTOU in subscriptions produces silent data loss: The gap between subscribe response and active subscription is typically milliseconds, but in high-throughput systems with message brokers or database-backed subscription stores, it can be hundreds of milliseconds. A resource that updates at high frequency (e.g., a live log file or sensor feed) will silently drop updates during this window. From the client’s perspective, the resource appears stale without any error signal.

HIGH
SA-MCP-RSB-002 — subscription confirmation returned before subscription broker registration completes: resource changes during registration window are silently dropped; client reads stale initial state. Detection: fire-and-forget subscription registration after subscribe response → HIGH.

SA-MCP-RSB-003: no per-session subscription rate limit enables subscription flood

MCP servers that do not enforce a per-session limit on the number of active resource subscriptions allow a single session to create unlimited subscriptions. Each subscription consumes memory in the server’s subscription map and requires the server to evaluate each new resource change against every active subscription. A malicious MCP client (or a compromised session) can create thousands of subscriptions across all resource URIs, causing memory exhaustion and O(N) notification routing overhead on every resource change.

// VULNERABLE: no subscription limit — unlimited subscriptions per session
const subscriptions = new Map();
server.setRequestHandler('resources/subscribe', async (req, context) => {
  const subId = randomBytes(16).toString('hex');
  subscriptions.set(subId, { sessionId: context.sessionId, uri: req.params.uri });
  return { subscriptionId: subId };
  // No check on how many subscriptions context.sessionId already has
  // Attacker creates 100,000 subscriptions → 100K entries in memory map
  // Every resource change triggers 100K subscription evaluations
});

// ATTACK: subscription flood
for (let i = 0; i < 100000; i++) {
  await client.request({
    method: 'resources/subscribe',
    params: { uri: `file:///resource_${i}.txt` }  // enumerate all resource URIs
  });
}
// Result: server memory consumed; notification routing O(N) on every change

// SECURE: enforce per-session subscription limit
const MAX_SUBS_PER_SESSION = 100;
server.setRequestHandler('resources/subscribe', async (req, context) => {
  // Count existing subscriptions for this session
  const sessionSubCount = [...subscriptions.values()]
    .filter(s => s.sessionId === context.sessionId).length;
  if (sessionSubCount >= MAX_SUBS_PER_SESSION) {
    throw new McpError(ErrorCode.InvalidRequest,
      `Subscription limit reached (max ${MAX_SUBS_PER_SESSION} per session)`);
  }
  const subId = randomBytes(16).toString('hex');
  subscriptions.set(subId, { sessionId: context.sessionId, uri: req.params.uri });
  return { subscriptionId: subId };
});

// ALSO SECURE: deduplicate subscriptions to same URI per session
server.setRequestHandler('resources/subscribe', async (req, context) => {
  const existing = [...subscriptions.values()]
    .find(s => s.sessionId === context.sessionId && s.uri === req.params.uri);
  if (existing) return { subscriptionId: existing.subId };  // return existing
  // ...create new subscription
});

// DETECTION (SkillAudit):
// Check resources/subscribe handler for rate limiting
const subHandler = findRequestHandler('resources/subscribe');
if (!hasSubscriptionCountCheck(subHandler)) → HIGH // SA-MCP-RSB-003
if (!hasMaxSubscriptionConstant(subHandler)) → HIGH // SA-MCP-RSB-003

Subscription flood is resource exhaustion without requiring CPU: Unlike computation-intensive DoS, subscription flood consumes memory proportional to subscription count. In a multi-tenant MCP server where multiple users share a process, one malicious session can exhaust memory for all users. The server does not need to be under computational load — simply holding 100K subscription entries in a Map is sufficient to trigger OOM on most serverless runtimes with 512MB memory limits.

HIGH
SA-MCP-RSB-003 — no per-session limit on active resource subscriptions: single session creates unlimited subscriptions causing memory exhaustion and O(N) notification routing overhead. Detection: missing subscription count check in resources/subscribe handler → HIGH.

SA-MCP-RSB-004: missing access check allows subscribing to unauthorized resources

MCP servers that implement resource access controls on resources/read but not on resources/subscribe create an authorization bypass: a session that cannot read a resource can still subscribe to it and receive notifications/resources/updated notifications containing the resource’s new URI and potentially its updated content. The subscribe pathway is a common authorization bypass because the access check is often added to the read handler but not to the subscription handler.

// VULNERABLE: access check on resources/read but not on resources/subscribe
server.setRequestHandler('resources/read', async (req, context) => {
  const resource = await getResource(req.params.uri);
  if (!hasReadAccess(context.sessionId, resource)) {
    throw new McpError(ErrorCode.InvalidRequest, 'Access denied');
  }
  return { contents: [{ uri: resource.uri, text: resource.content }] };
});

server.setRequestHandler('resources/subscribe', async (req, context) => {
  // NO ACCESS CHECK — any session can subscribe to any resource URI
  const subId = randomBytes(16).toString('hex');
  subscriptions.set(subId, { sessionId: context.sessionId, uri: req.params.uri });
  return { subscriptionId: subId };
  // Session without read access now receives notifications/resources/updated
  // Notification contains uri of changed resource → presence oracle
  // If notification includes updated content → full unauthorized read via subscription
});

// SECURE: same access check on subscribe as on read
server.setRequestHandler('resources/subscribe', async (req, context) => {
  const resource = await getResource(req.params.uri);
  if (!hasReadAccess(context.sessionId, resource)) {
    throw new McpError(ErrorCode.InvalidRequest, 'Access denied');
  }
  // Access verified → proceed with subscription
  const subId = randomBytes(16).toString('hex');
  subscriptions.set(subId, { sessionId: context.sessionId, uri: req.params.uri });
  return { subscriptionId: subId };
});

// ALSO: re-check access on notification delivery (access may be revoked post-subscribe)
async function deliverResourceUpdate(uri, newContent) {
  for (const [subId, sub] of subscriptions) {
    const resource = await getResource(uri);
    if (sub.uri === uri && hasReadAccess(sub.sessionId, resource)) {
      await sendNotification(sub.sessionId, {
        method: 'notifications/resources/updated',
        params: { uri }
      });
    }
  }
}

// DETECTION (SkillAudit):
// Compare access control logic between resources/read and resources/subscribe
const readHandler = findRequestHandler('resources/read');
const subHandler = findRequestHandler('resources/subscribe');
if (hasAccessCheck(readHandler) && !hasAccessCheck(subHandler)) → CRITICAL // RSB-004
// Check notification delivery path for access re-validation
if (!hasAccessCheckInNotificationDelivery('resources/updated')) → HIGH // RSB-004

Subscription authorization bypass is the most common RSB finding in community MCP servers: Developers add access checks when they write the read handler and forget to apply the same check to the subscribe handler because subscriptions do not immediately return resource content — only a subscription ID. The authorization bypass is only exploited when notifications start arriving. A session with no read access can silently receive every update to a private resource until the subscription is explicitly cancelled.

CRITICAL
SA-MCP-RSB-004 — resources/subscribe handler lacks the same access control as resources/read: sessions without read permission receive push notifications for private resource changes. Detection: access check present on read handler but absent on subscribe handler → CRITICAL.

How SkillAudit detects SA-MCP-RSB patterns

PatternDetection methodSeverity
SA-MCP-RSB-001Static: subscription ID generation scanned for sequential counters, timestamps, deterministic hashesCRITICAL
SA-MCP-RSB-002Static: subscribe handler scanned for fire-and-forget broker registration (missing await)HIGH
SA-MCP-RSB-003Static: subscribe handler scanned for per-session subscription count enforcementHIGH
SA-MCP-RSB-004Static: access control comparison between resources/read and resources/subscribe handlersCRITICAL

Run a SkillAudit scan on your MCP server’s subscription implementation: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-RSB patterns and 200+ additional MCP security checks.