Claude skill memory and state leakage security
Claude skills are designed to be stateless per-request, but Node.js module state persists for the lifetime of the process. In single-user, single-machine deployments this is benign. But skills deployed to hosted MCP proxies, team servers, or multi-tenant Claude platforms run one process serving many users simultaneously. Module-level globals, in-process caches, and session Maps that accumulate state across invocations create cross-user data leakage: one user’s tool arguments, credentials, or query results become visible to the next user who triggers the right tool call. The SA-SKL-MEM family covers four patterns where in-process state bleeds across user boundaries.
The safe mental model for a Claude skill is that it is stateless: each tool call receives its arguments, performs its operation, returns a result, and discards all working state. The dangerous mental model — common among developers who write Node.js web servers — is that module-level variables are a convenient place to cache data between calls. In a single-tenant local deployment, these two models produce the same observable behavior. In a hosted deployment, the second model leaks one user’s data to another. SA-SKL-MEM patterns flag the code structures that bridge these two models in the wrong direction.
SA-SKL-MEM-001: module-level global variables capture previous user’s tool call arguments
JavaScript modules are loaded once per process and their top-level state persists. A skill that assigns tool arguments to module-level variables (e.g., to “remember” the last query for a follow-up call) retains those values until overwritten. In a multi-user hosted deployment, user A’s arguments are written to the global, then user B’s tool call reads the global before overwriting it — or user B’s call uses a code path that reads the global without overwriting it first. This is a classic TOCTOU-style state confusion: the global contains whichever user last set it, not necessarily the current user.
// VULNERABLE: module-level variable captures last user's arguments
let lastQuery = null; // ← persists across all users' requests in hosted deployments
let currentUserContext = {}; // ← same
let activeFilters = []; // ← same
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'search') {
const { query, filters } = req.params.arguments;
lastQuery = query; // ← overwrites previous user's query
activeFilters = filters; // ← overwrites previous user's filters
const results = await doSearch(query, filters);
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
}
if (req.params.name === 'search_more') {
// "Refine previous search" — but lastQuery is from WHOEVER called search last
const results = await doSearch(lastQuery, activeFilters); // ← cross-user leakage
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
}
});
// ATTACK: concurrent users
// User A: calls search({ query: "confidential project plan", filters: [...] })
// User B: calls search_more() immediately after → receives User A's search results
// SECURE: pass state explicitly through arguments; never use module-level mutable state
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'search') {
const { query, filters } = req.params.arguments;
// No module-level state written
const results = await doSearch(query, filters);
return {
content: [{ type: 'text', text: JSON.stringify(results) }],
// Return a cursor/token the client can pass to search_more — no server-side state
meta: { searchToken: encodeSearchToken({ query, filters, page: 0 }) }
};
}
if (req.params.name === 'search_more') {
const { searchToken } = req.params.arguments;
// State comes from the caller's argument — no cross-user contamination
const { query, filters, page } = decodeSearchToken(searchToken);
const results = await doSearch(query, filters, page + 1);
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
}
});
// DETECTION (SkillAudit):
// Find module-level let/var declarations that are assigned in request handlers
const mutableModuleGlobals = findMutableModuleLevelVarsAssignedInHandlers();
if (mutableModuleGlobals.length > 0) → HIGH // SA-SKL-MEM-001
// Find reads of module-level state in handlers where the state was set by a different handler
const crossHandlerStateReads = findCrossHandlerStateReads(mutableModuleGlobals);
if (crossHandlerStateReads.length > 0) → CRITICAL // SA-SKL-MEM-001
Module-level state leakage is invisible in local testing: When a skill is tested locally with a single user, module-level globals behave correctly because only one user ever sets them. The leakage only manifests in a multi-user deployment where concurrent or sequential users share the same process. This means a skill can pass all local tests and code review, ship to a hosted platform, and immediately leak data between users — without any error being thrown.
SA-SKL-MEM-002: Map/WeakMap session state not cleared between sessions bleeds data across users
Skills that use in-process Maps to store session state — a common pattern for implementing conversation context, authentication state, or multi-step workflows — leak data when session keys are predictable, reused, or never cleaned up. An attacker who can predict the next session key can pre-populate the Map with malicious state before a victim session starts. An attacker who observes session key exhaustion (numeric IDs) can read stale Map entries from other sessions. And Maps that grow indefinitely without TTL-based eviction become an information store that any process introspection technique (e.g., heap snapshots, --inspect protocol) can dump.
// VULNERABLE: session Map keyed on predictable or reused session IDs
const sessions = new Map(); // module-level — persists for process lifetime
let sessionCounter = 0; // ← sequential, predictable
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'start_session') {
const { userId } = req.params.arguments;
const sessionId = ++sessionCounter; // ← attacker can guess next session ID
sessions.set(sessionId, { userId, history: [], createdAt: Date.now() });
return { content: [{ type: 'text', text: String(sessionId) }] };
}
if (req.params.name === 'get_history') {
const { sessionId } = req.params.arguments;
// No ownership check — any caller can read any session ID's history
const session = sessions.get(parseInt(sessionId, 10));
if (session) {
return { content: [{ type: 'text', text: JSON.stringify(session.history) }] };
}
}
});
// ATTACK: user guesses session IDs of others (1, 2, 3...) and reads their history
// ALSO VULNERABLE: no TTL — sessions accumulate indefinitely, memory grows unbounded
// ALSO VULNERABLE: WeakMap keyed on object reconstructed from JSON — loses WeakMap semantics
// SECURE: cryptographically random session IDs + ownership check + TTL eviction
const crypto = require('crypto');
const sessions = new Map(); // still module-level, but keyed on unguessable tokens
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
function cleanExpiredSessions() {
const now = Date.now();
for (const [key, session] of sessions) {
if (now - session.createdAt > SESSION_TTL_MS) {
sessions.delete(key);
}
}
}
setInterval(cleanExpiredSessions, 5 * 60 * 1000); // clean every 5 minutes
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'start_session') {
const { userId } = req.params.arguments;
// Unguessable 256-bit session token
const sessionId = crypto.randomBytes(32).toString('base64url');
sessions.set(sessionId, { userId, history: [], createdAt: Date.now() });
return { content: [{ type: 'text', text: sessionId }] };
}
if (req.params.name === 'get_history') {
const { sessionId, userId } = req.params.arguments;
const session = sessions.get(sessionId);
if (!session || session.userId !== userId) { // ← ownership check
throw new Error('Session not found');
}
if (Date.now() - session.createdAt > SESSION_TTL_MS) {
sessions.delete(sessionId);
throw new Error('Session expired');
}
return { content: [{ type: 'text', text: JSON.stringify(session.history) }] };
}
});
// DETECTION (SkillAudit):
// Find Map/WeakMap used as session store without TTL eviction
const sessionMaps = findSessionMapsWithoutTTL();
if (sessionMaps.length > 0) → HIGH // SA-SKL-MEM-002
// Find session key generation using sequential integers or timestamps
if (hasSequentialSessionKeys()) → CRITICAL // SA-SKL-MEM-002
// Find Map.get() calls without ownership check before accessing session data
if (hasSessionGetWithoutOwnershipCheck()) → CRITICAL // SA-SKL-MEM-002
In-process session Maps leak on process crashes and heap dumps: Beyond cross-user leakage during normal operation, a Map that accumulates all session history for the process lifetime creates a high-value target for memory extraction attacks. A process crash with core dump, a --inspect debugger attachment, or a heap snapshot via the v8 module would expose the complete session history for every user who ever connected to the hosted skill instance.
SA-SKL-MEM-003: in-process response cache keyed without user isolation serves one user’s data to another
Skills that cache API responses, database query results, or computed values in module-level Maps or LRU caches to improve performance create cross-user data leakage when the cache key does not include the requesting user’s identity. A request for user A’s data is cached under a key derived from the query parameters alone; user B’s request with the same parameters hits the cache and receives user A’s data. This pattern is especially dangerous when the cached data is user-specific (account details, private documents, personal preferences) and the cache key naturally collides between users (e.g., "latest_results", "current_user_profile", or a query string without user scoping).
// VULNERABLE: cache keyed on query alone — shared across all users
const cache = new Map(); // module-level response cache
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'get_account') {
const { accountId } = req.params.arguments;
const cacheKey = `account:${accountId}`; // ← no user identity in key
if (cache.has(cacheKey)) {
return { content: [{ type: 'text', text: cache.get(cacheKey) }] };
}
// context.userId would scope this — but cache key doesn't include it
const account = await db.query('SELECT * FROM accounts WHERE id = ?', [accountId]);
const result = JSON.stringify(account);
cache.set(cacheKey, result); // ← user A's account cached under account:{id}
return { content: [{ type: 'text', text: result }] };
}
});
// ATTACK: user A requests account:123 → cached
// User B requests account:123 (same ID, different user context) → gets user A's cached data
// Even if DB query would filter by auth context, cache bypasses that filter
// ALSO VULNERABLE: single-key cache for "current" result
const cachedProfile = { data: null, timestamp: 0 };
// Any user's "get_my_profile" call overwrites cachedProfile.data
// Next user's call reads previous user's profile until TTL expires
// SECURE: include user identity in every cache key
server.setRequestHandler('tools/call', async (req, context) => {
if (req.params.name === 'get_account') {
const { accountId } = req.params.arguments;
// Cache key includes the authenticated user's ID — different users get different cache entries
const cacheKey = `${context.userId}:account:${accountId}`;
if (cache.has(cacheKey)) {
const entry = cache.get(cacheKey);
if (Date.now() - entry.ts < 60_000) { // 60-second TTL
return { content: [{ type: 'text', text: entry.data }] };
}
cache.delete(cacheKey);
}
// Auth check: verify context.userId can access accountId
const account = await db.query(
'SELECT * FROM accounts WHERE id = ? AND user_id = ?',
[accountId, context.userId]
);
if (!account) throw new Error('Account not found');
const result = JSON.stringify(account);
cache.set(cacheKey, { data: result, ts: Date.now() });
return { content: [{ type: 'text', text: result }] };
}
});
// DETECTION (SkillAudit):
// Find cache key constructions that don't include context.userId or session identifier
const cacheKeyExprs = findCacheKeyExpressions();
for (const keyExpr of cacheKeyExprs) {
if (!includesUserIdentifier(keyExpr)) → HIGH // SA-SKL-MEM-003
if (isStaticString(keyExpr)) → CRITICAL // SA-SKL-MEM-003 (key "latest" shared globally)
}
// Find module-level single-value caches (not Maps) that store query results
if (hasSingleValueModuleLevelCache()) → HIGH // SA-SKL-MEM-003
Cache keys that look correct locally are wrong in hosted deployments: A cache key like account:{accountId} is correct in a single-user deployment because only one user’s requests arrive. In a hosted deployment, the same accountId for two different users (who both have access to account 123 in their respective tenants) produces a cache collision. The correct invariant is: any data returned from cache must be data this user was authorized to receive. The only way to guarantee this is to include the user identity in every cache key that caches user-scoped data.
context.userId or session identifier for data that varies per user → HIGH.SA-SKL-MEM-004: global arrays accumulate tool arguments across all invocations in hosted deployments
Skills that use module-level arrays to accumulate invocation history — for analytics, debugging, context building, or “recent queries” features — store every user’s tool arguments in a shared in-process array. Any code path that reads or returns from this array exposes all previous users’ invocation data to the current user. This pattern is particularly common in skills that try to implement a “history” feature or that log arguments for debugging and forget to scope the log to the current session.
// VULNERABLE: global array accumulates all users' invocation arguments
const invocationHistory = []; // ← grows indefinitely, crosses user boundaries
const recentQueries = []; // ← same
const debugLog = []; // ← same
server.setRequestHandler('tools/call', async (req, context) => {
if (req.params.name === 'search_documents') {
const { query, filters } = req.params.arguments;
// "History" feature — but shared across all users in hosted deployment
invocationHistory.push({
query,
filters,
userId: context.userId,
timestamp: Date.now()
});
const results = await searchDocs(query, filters);
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
}
if (req.params.name === 'get_recent_searches') {
// Returns all users' recent searches — not scoped to current user
return {
content: [{
type: 'text',
text: JSON.stringify(invocationHistory.slice(-20)) // last 20 across ALL users
}]
};
}
});
// ATTACK: user B calls get_recent_searches → receives user A's query terms and filters
// ALSO VULNERABLE: unbounded growth → OOM in long-running hosted processes
// After 10k users × 100 calls = 1M entries in invocationHistory → memory exhaustion
// SECURE: never accumulate cross-user history in module-level arrays;
// scope per-user history to a database or session-bound structure
const userHistories = new Map(); // userId → circular buffer (max 20 entries)
function addToHistory(userId, entry) {
const history = userHistories.get(userId) ?? [];
history.push(entry);
if (history.length > 20) history.shift(); // bounded circular buffer
userHistories.set(userId, history);
}
server.setRequestHandler('tools/call', async (req, context) => {
if (req.params.name === 'search_documents') {
const { query, filters } = req.params.arguments;
addToHistory(context.userId, { query, filters, timestamp: Date.now() });
const results = await searchDocs(query, filters);
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
}
if (req.params.name === 'get_recent_searches') {
// Scoped to authenticated user — never returns other users' history
const history = userHistories.get(context.userId) ?? [];
return { content: [{ type: 'text', text: JSON.stringify(history) }] };
}
});
// ALSO SECURE: persist history to database with userId column + read with WHERE userId = ?
// ALSO SECURE: if history is for debugging only, log to structured log with user-scoped filter
// DETECTION (SkillAudit):
// Find module-level array.push() calls in tool handlers
const moduleArrayPushes = findModuleLevelArrayPushesInHandlers();
if (moduleArrayPushes.length > 0) → HIGH // SA-SKL-MEM-004
// Find module-level array reads in response paths (slice, map, filter, join)
const moduleArrayReads = findModuleLevelArrayReadsInResponsePaths();
if (moduleArrayReads.length > 0) → CRITICAL // SA-SKL-MEM-004
// Find unbounded accumulation (no max size check, no TTL, no periodic clear)
if (hasUnboundedAccumulation(moduleArrayPushes)) → HIGH // SA-SKL-MEM-004 (OOM risk)
Unbounded global arrays cause memory exhaustion in long-running hosted processes: Beyond cross-user data leakage, a module-level array that grows by one entry per tool call will consume gigabytes of memory after enough users. In a hosted MCP server with hundreds of concurrent users each making dozens of calls per session, an unbounded array can crash the process in hours. The security finding (cross-user leakage) and the reliability finding (OOM) have the same root cause: shared mutable global state without lifecycle management.
array.push() in handlers + array read in response path without userId filter → CRITICAL.How SkillAudit detects SA-SKL-MEM patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-SKL-MEM-001 | Static: mutable module-level variables assigned in one request handler and read in another without session binding; taint analysis from handler argument to module-level write to cross-handler read | CRITICAL |
| SA-SKL-MEM-002 | Static: session Map with sequential integer keys; Map.get() without ownership check; no TTL eviction interval for session storage | CRITICAL |
| SA-SKL-MEM-003 | Static: cache key construction omitting context.userId or session identifier for data that varies per user; static string cache keys for user-scoped data | HIGH |
| SA-SKL-MEM-004 | Static: module-level array.push() calls in tool handlers; module-level array reads in response construction without user-identity filter | CRITICAL |
Run a SkillAudit scan on your Claude skill or MCP server: Paste your GitHub URL at skillaudit.dev to detect all four SA-SKL-MEM memory leakage patterns plus SA-SKL-ENV credential leakage, SA-SKL-NET network egress, and 200+ other checks in the full SkillAudit security taxonomy.