Security·Multitenancy·Tenant Isolation

MCP server multitenancy security: tenant isolation, credential separation, and cross-tenant data leakage prevention

Multitenancy is a security multiplier: every isolation failure in a single-tenant server becomes a cross-tenant data breach in a shared deployment. An MCP server that serves ten tenants from the same process must ensure that tool calls, cached responses, audit logs, rate limit counters, error messages, and upstream API credentials are strictly segregated. A single leaked tenant context — from a shared in-memory cache, an incorrectly scoped database query, or an API key stored in process-global state — exposes one tenant's data to every other tenant.

Isolation levels: choose your architecture first

The isolation level you choose determines which attack surfaces exist. Higher isolation means more operational overhead but fewer shared failure modes:

Level What's shared Cross-tenant risk
Process-per-tenant Nothing (separate OS process) Minimal — requires OS-level exploit
Namespace isolation Binary, some OS resources Low — requires container escape
Request-scoped context Process memory, connection pools Medium — context leak via shared state
Single context, row-level filter Everything except DB rows High — one missed filter = breach

For MCP servers handling sensitive data (credentials, PII, financial records), process-per-tenant or namespace isolation is preferred. For lower-sensitivity use cases, request-scoped context is acceptable if implemented correctly.

Request-scoped tenant context: the correct pattern

If you use the request-scoped model, the tenant context must be established at authentication, passed explicitly through every function call, and never stored in module-level or process-global state. The most common cross-tenant leakage bug is a module-level cache keyed only on the resource identifier, not on tenantId + resourceId:

// WRONG — shared cache across tenants
const cache = new Map<string, RepoData>();
async function getRepo(repoId: string) {
  if (cache.has(repoId)) return cache.get(repoId)!;
  const data = await fetchRepo(repoId);
  cache.set(repoId, data); // Tenant A's data visible to Tenant B
  return data;
}

// CORRECT — tenant-scoped cache key
const cache = new Map<string, RepoData>();
async function getRepo(tenantId: string, repoId: string) {
  const key = `${tenantId}:${repoId}`;
  if (cache.has(key)) return cache.get(key)!;
  const data = await fetchRepoForTenant(tenantId, repoId);
  cache.set(key, data);
  return data;
}

Per-tenant credential management

Each tenant's API keys and tokens must be isolated from other tenants at both storage and retrieval time. The pattern is to store tenant credentials in a secrets manager (AWS Secrets Manager, Vault, or similar) keyed by tenantId, and retrieve them only after the tenant context is authenticated:

// src/tenant-credentials.ts
import { SecretsManager } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManager({ region: process.env.AWS_REGION });
const credCache = new Map<string, { value: string; expiresAt: number }>();

export async function getTenantCredential(
  tenantId: string,
  credentialType: 'github_token' | 'jira_token',
): Promise<string> {
  const secretName = `mcp-server/${tenantId}/${credentialType}`;
  const cacheKey = secretName;
  const now = Date.now();

  const cached = credCache.get(cacheKey);
  if (cached && cached.expiresAt > now) return cached.value;

  const result = await client.getSecretValue({ SecretId: secretName });
  const value = result.SecretString!;

  // Cache for 5 minutes — avoids per-call Secrets Manager latency
  credCache.set(cacheKey, { value, expiresAt: now + 300_000 });
  return value;
}

Critical: the tenantId used to look up the credential comes from the verified API key lookup, not from a request parameter. A tenant must not be able to specify another tenant's tenantId in a tool argument and retrieve their credentials.

Audit log isolation

Audit logs in multi-tenant deployments must include tenantId on every event and must be stored or exported in a way that prevents one tenant from querying another's activity. The minimum requirement is that every log entry carries a non-spoofable tenantId field set by the auth layer, not by the request:

// Auth layer sets the tenantContext — callers cannot override it
export function verifyApiKey(apiKey: string): TenantContext {
  const record = apiKeyStore.lookup(apiKey);
  if (!record) throw new McpError(ErrorCode.Unauthorized, 'Invalid API key');
  return { tenantId: record.tenantId, callerId: record.callerId };
}

// Audit log — tenantId comes from verified context, not request args
export function emitAuditEvent(ctx: TenantContext, event: AuditPayload) {
  console.log(JSON.stringify({
    ts: new Date().toISOString(),
    tenantId: ctx.tenantId,  // verified — not from request
    callerId: ctx.callerId,
    ...event,
  }));
}

Per-tenant rate limiting

Rate limits must be scoped per tenant, not per server or per tool globally. A noisy tenant filling the global rate limit bucket denies service to all other tenants — a form of accidental (or deliberate) denial of service. The counter key must include tenantId:

// Rate limit key includes tenantId — isolation guaranteed
const key = `ratelimit:${tenantId}:${tool}:${Math.floor(Date.now() / 60000)}`;

What SkillAudit checks for in multi-tenant deployments

SkillAudit's static analysis flags multitenancy isolation failures at the code level:

Scan your multi-tenant MCP server for isolation failures

Run a free audit →