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:
- CRITICAL: shared module-level state not scoped by tenant — a
Map,Set, or plain object at module scope used as a cache or state store without atenantIdkey component. - HIGH:
tenantIdsourced from tool arguments — any code path wheretenantIdis read fromargsrather than from a verified authentication context. - HIGH: upstream API call with credential from process environment — a single shared API token used for all tenants. Each tenant should have their own credential.
- MEDIUM: audit log events missing
tenantIdfield — audit events that cannot be attributed to a specific tenant during investigation.
Scan your multi-tenant MCP server for isolation failures
Run a free audit →