Topic: mcp server multi-tenancy security

MCP server multi-tenancy security — tenant isolation failures, cross-tenant data leakage, and shared credential bleed

An MCP server that serves multiple organizations or users from a single process must enforce tenant isolation at every layer: the request context, the in-memory state the handler reads and writes, the database queries it executes, and the upstream API credentials it uses. The failure mode is typically not a deliberate bypass — it's module-level state that the developer did not intend to be shared but is shared by default in Node's single-process model.

Module-level state is the most common isolation failure

In Node.js, module-level variables are singletons: every request handler that imports the module shares the same variable instance. A developer who stores the current tenant's configuration in a module-level object for convenience is storing it in a shared location that any concurrent request can read:

// Dangerous: module-level state shared across all concurrent requests
let currentTenantConfig = null; // shared by all request handlers

server.tool('getConfig', {
  handler: async (args, context) => {
    currentTenantConfig = await loadTenantConfig(context.tenantId); // Race: another request overwrites this
    const result = await processWithConfig(currentTenantConfig); // May use wrong tenant's config
    return result;
  }
});

// Safe: use AsyncLocalStorage to propagate tenant context without global state
const { AsyncLocalStorage } = require('async_hooks');
const tenantStore = new AsyncLocalStorage();

// Middleware sets tenant context at request entry
function withTenantContext(tenantId, fn) {
  return tenantStore.run({ tenantId }, fn);
}

// Any code within the async call chain can access the tenant context
function getCurrentTenant() {
  const store = tenantStore.getStore();
  if (!store) throw new Error('No tenant context set — possible missing middleware');
  return store.tenantId;
}

server.tool('getConfig', {
  handler: async (args, context) => {
    return withTenantContext(context.tenantId, async () => {
      const tenantId = getCurrentTenant(); // isolated per request
      const config = await loadTenantConfig(tenantId);
      return processWithConfig(config);
    });
  }
});

Shared database connection with insufficient row-level filtering

A single database connection pool shared across all tenants is efficient, but every query must include a tenant-scoping filter to prevent cross-tenant data access. The failure mode: a developer adds a new query for a new feature and forgets the tenant filter, or a developer refactors a scoped query into a helper function that omits the tenant scope in one code path:

// Dangerous: query missing tenant filter
async function getRecentAlerts(limit) {
  return db.query('SELECT * FROM alerts ORDER BY created_at DESC LIMIT $1', [limit]);
  // Returns alerts from ALL tenants — cross-tenant data leakage
}

// Safe: tenant ID is always a required parameter, never optional
async function getRecentAlerts(tenantId, limit) {
  if (!tenantId) throw new Error('tenantId required for all data access queries');
  return db.query(
    'SELECT * FROM alerts WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2',
    [tenantId, limit]
  );
}

// Even safer: database-level row security policies (PostgreSQL RLS)
// SET LOCAL app.current_tenant = $1 — enforced at the database engine layer,
// not just in application code that can be bypassed by a missed filter

PostgreSQL Row Level Security (RLS) provides a database-engine-level backstop: even if application code omits the tenant filter, the database policy enforces it. This is the defense-in-depth approach for multi-tenant MCP servers with sensitive data.

In-memory cache bleed between tenants

A module-level cache (Map, LRU cache, or similar) that stores tool results without tenant-scoped keys will return one tenant's cached response to a different tenant requesting the same logical resource:

// Dangerous: cache keyed on resource ID only — no tenant scope
const cache = new Map();
server.tool('getDocument', {
  handler: async ({ docId }, context) => {
    if (cache.has(docId)) return cache.get(docId); // returns tenant A's doc to tenant B
    const doc = await db.getDocument(docId, context.tenantId);
    cache.set(docId, doc); // cached without tenant scope
    return doc;
  }
});

// Safe: cache key includes tenant ID
server.tool('getDocument', {
  handler: async ({ docId }, context) => {
    const cacheKey = `${context.tenantId}:${docId}`;
    if (cache.has(cacheKey)) return cache.get(cacheKey);
    const doc = await db.getDocument(docId, context.tenantId);
    // Also verify the doc actually belongs to this tenant before caching
    if (doc.tenantId !== context.tenantId) {
      throw new Error('Document does not belong to requesting tenant');
    }
    cache.set(cacheKey, doc);
    return doc;
  }
});

Shared upstream API credentials across tenants

An MCP server that holds a single upstream API credential and uses it for all tenant requests conflates authentication (who the server is) with authorization (which tenant's data should be accessible). If the upstream API enforces per-tenant data boundaries using the API key, a single key gives access to all data — a credential compromise affects all tenants simultaneously. Per-tenant credential isolation is the mitigation:

// Dangerous: single API key for all tenants
const apiClient = new UpstreamAPI({ key: process.env.API_KEY }); // all tenants share one key

// Safe: per-tenant API key, loaded from tenant configuration at request time
server.tool('fetchTenantData', {
  handler: async (args, context) => {
    const tenantCreds = await credentialStore.getTenantCredentials(context.tenantId);
    const client = new UpstreamAPI({ key: tenantCreds.apiKey });
    return client.fetch(args.resourceId);
    // client instance is request-scoped — not cached at module level
  }
});

Context injection via tenant ID spoofing

If the tenant context is passed as a tool argument rather than derived from a validated authentication token, any LLM-controlled argument becomes a tenant selector. Prompt injection that changes tenantId to another tenant's ID bypasses all tenant-scoped queries:

// Dangerous: tenantId in tool arguments — LLM can be prompted to supply wrong value
server.tool('getData', {
  schema: { tenantId: { type: 'string' }, resourceId: { type: 'string' } },
  handler: async ({ tenantId, resourceId }) => {
    return db.getResource(resourceId, tenantId); // attacker sets tenantId = victim_tenant
  }
});

// Safe: tenantId comes from authentication context, never from tool arguments
server.tool('getData', {
  schema: { resourceId: { type: 'string' } }, // tenantId NOT in schema
  handler: async ({ resourceId }, context) => {
    // context.tenantId is set by the authentication middleware from the session token
    return db.getResource(resourceId, context.tenantId);
  }
});

What SkillAudit checks for multi-tenancy security

Multi-tenancy isolation failures are HIGH severity findings in the Security and Permissions axes. Cross-tenant data access is typically a data breach event, not just a vulnerability. SOC 2 compliance requires demonstrating that tenant isolation is enforced at the system level, not just at the application level. Run a free SkillAudit scan to check for tenant isolation issues in your MCP server before deploying it in a multi-tenant context.