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.
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.
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.
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.
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.
How SkillAudit detects SA-MCP-RSB patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-MCP-RSB-001 | Static: subscription ID generation scanned for sequential counters, timestamps, deterministic hashes | CRITICAL |
| SA-MCP-RSB-002 | Static: subscribe handler scanned for fire-and-forget broker registration (missing await) | HIGH |
| SA-MCP-RSB-003 | Static: subscribe handler scanned for per-session subscription count enforcement | HIGH |
| SA-MCP-RSB-004 | Static: access control comparison between resources/read and resources/subscribe handlers | CRITICAL |
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.