Topic: mcp server multi-tenant security
MCP server multi-tenant security — data isolation, tenant context leakage, and row-level security
A multi-tenant MCP server handles requests from many different organizations or users through the same process, database, cache, and filesystem. The security contract is that no tenant can read, modify, or infer data belonging to another. That contract is broken surprisingly often — not by sophisticated attacks but by missing a single predicate in a SQL query, a cache key that omits the tenant dimension, or an error handler that echoes back another tenant's resource name. The four patterns below are the most common tenant-isolation failures found across the SkillAudit corpus.
Pattern 1: Missing tenant isolation in DB queries — IDOR via sequential resource IDs
An MCP tool that fetches a resource by its primary key without also filtering by tenant ID is vulnerable to Insecure Direct Object Reference (IDOR). If resource IDs are sequential integers — as they are with auto-increment columns in PostgreSQL or MySQL — an attacker can enumerate every record in the table by incrementing the ID parameter. The fix is always to include a tenant predicate: the database must validate both the resource ID and the tenant ownership in a single query, so the row is only returned if both conditions are true.
The pattern applies to all CRUD operations. A missing tenant filter on DELETE is especially dangerous because it allows cross-tenant record deletion. Row-level security (RLS) in PostgreSQL can enforce the tenant predicate at the database engine level as a second line of defence, so that even a raw query bypassing application logic is blocked.
WRONG — fetching by resource ID alone without tenant scoping
// MCP tool: getDocument({ documentId })
// tenantId comes from the authenticated session, documentId from the caller
async function getDocument(db, tenantId, documentId) {
// WRONG: fetches the document regardless of which tenant owns it
const row = await db.query(
'SELECT * FROM documents WHERE id = $1',
[documentId] // WRONG: no tenant_id predicate
);
if (!row) throw new Error('Not found');
return row;
// Caller can enumerate any document in the table by incrementing documentId
}
RIGHT — always include tenant_id in the WHERE clause for every data query
// RIGHT: the query only returns a row if BOTH id AND tenant_id match
async function getDocument(db, tenantId, documentId) {
const row = await db.query(
'SELECT * FROM documents WHERE id = $1 AND tenant_id = $2',
[documentId, tenantId] // RIGHT: tenant predicate is mandatory
);
if (!row) throw new Error('Not found'); // same message for not-found and unauthorized
return row;
}
// For PostgreSQL, add RLS as a second enforcement layer:
//
// ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
//
// CREATE POLICY tenant_isolation ON documents
// USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
//
// -- Set at connection time:
// await db.query("SET app.current_tenant_id = $1", [tenantId]);
//
// Even if application code omits the WHERE clause, the RLS policy blocks cross-tenant reads.
Pattern 2: Tenant context leakage via caching — cached response served to wrong tenant
A shared in-process or remote cache (Redis, Memcached) where keys are scoped only to the resource identifier — not the tenant — will serve tenant A's data to tenant B whenever both tenants access the same resource ID. This is especially dangerous for computed or aggregated results (e.g. report summaries, user counts) where the cached value was built from tenant A's rows but the cache key omits the tenant dimension.
The fix is mechanical: every cache key for tenant-scoped data must include the tenant identifier as the first path segment. Any cache population code that stores a result must use the same key structure as the lookup code, and both must be derived from the same cacheKey(tenantId, ...) function to prevent key-format drift.
WRONG — cache key is the resource ID alone, without tenant scope
// WRONG: cache key is just the report ID — tenant A's result served to tenant B
async function getReport(cache, db, tenantId, reportId) {
const cacheKey = `report:${reportId}`; // WRONG: no tenant in key
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached); // WRONG: could be another tenant's data
const report = await db.query(
'SELECT * FROM reports WHERE id = $1 AND tenant_id = $2',
[reportId, tenantId]
);
await cache.set(cacheKey, JSON.stringify(report), 'EX', 300);
return report;
}
RIGHT — include tenant ID as the first segment of every cache key
// RIGHT: derive cache keys from a single function so the structure stays consistent
function reportCacheKey(tenantId, reportId) {
return `tenant:${tenantId}:report:${reportId}`; // tenant is always first segment
}
async function getReport(cache, db, tenantId, reportId) {
const cacheKey = reportCacheKey(tenantId, reportId); // RIGHT
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached); // safe — key is tenant-scoped
const report = await db.query(
'SELECT * FROM reports WHERE id = $1 AND tenant_id = $2',
[reportId, tenantId]
);
if (!report) throw new Error('Not found');
await cache.set(cacheKey, JSON.stringify(report), 'EX', 300);
return report;
}
// Invalidation must also use the scoped key:
async function invalidateReport(cache, tenantId, reportId) {
await cache.del(reportCacheKey(tenantId, reportId));
}
// To flush all cached data for a tenant (e.g. on account deletion):
// Use a key pattern like SCAN + DEL on "tenant:${tenantId}:*"
// or structure keys so each tenant has a dedicated Redis keyspace.
Pattern 3: Cross-tenant error messages — error responses revealing another tenant's data
Error handling code that re-serializes the failing object, includes the original query, or echoes back field values from a database row can leak tenant A's data in an error response sent to tenant B. This happens when a tool handler catches an error thrown deep in a shared utility function and forwards the error message directly to the caller, and that error message was constructed with data from the database row that triggered the failure — which may belong to a different tenant.
The rule is: at the tenant boundary (the MCP tool handler), all errors must be caught and replaced with a generic, non-data-bearing message before the response is returned. Log the full error internally with a correlation ID. Return only the correlation ID and a generic status string to the caller.
WRONG — forwarding raw error messages that may contain another tenant's data
// WRONG: error messages from deep utilities may contain data from other tenants
async function processInvoice(db, tenantId, invoiceId) {
try {
const invoice = await db.query(
'SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2',
[invoiceId, tenantId]
);
await validateInvoice(invoice); // may throw with invoice fields in message
await chargeCustomer(invoice); // may throw "Card declined for invoice #4521 (acme-corp)"
return { success: true };
} catch (err) {
// WRONG: err.message may contain invoice data belonging to another tenant
// if chargeCustomer fetches related records without tenant scoping
return { error: err.message }; // WRONG: leaks cross-tenant data
}
}
RIGHT — catch at the boundary, log internally, return generic message with correlation ID
import crypto from 'node:crypto';
async function processInvoice(db, logger, tenantId, invoiceId) {
// RIGHT: catch at the tenant boundary
try {
const invoice = await db.query(
'SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2',
[invoiceId, tenantId]
);
if (!invoice) throw new UserFacingError('Invoice not found');
await validateInvoice(invoice);
await chargeCustomer(invoice);
return { success: true };
} catch (err) {
if (err instanceof UserFacingError) {
// Safe — UserFacingError is constructed with pre-approved messages only
return { error: err.message };
}
// RIGHT: log full error internally, return opaque correlation ID to caller
const correlationId = crypto.randomUUID();
logger.error({ correlationId, tenantId, invoiceId, err }, 'invoice processing failed');
// err.message stays server-side — caller gets only the ID for support queries
return { error: 'Processing failed', correlationId };
}
}
// UserFacingError: only messages approved at construction time reach callers
class UserFacingError extends Error {
constructor(message) { super(message); this.name = 'UserFacingError'; }
}
Pattern 4: Shared filesystem paths — tenant data written to predictable paths without namespacing
When an MCP tool writes files to disk — exports, temporary processing files, generated reports — using a path derived only from the resource ID or a user-supplied filename, two problems emerge. First, a tenant can overwrite another tenant's file if both derive the same path. Second, an attacker can predict where another tenant's file will be written and read it directly if the filesystem is accessible through a separate file-serving tool. The fix is to namespace all tenant writes under a per-tenant subdirectory and to validate every write path against that prefix before the write, using the same zip-slip containment check described for archive extraction.
WRONG — writing tenant files to paths derived only from resource or user-supplied names
import path from 'node:path';
import fs from 'node:fs/promises';
const EXPORT_DIR = '/var/lib/mcp-server/exports';
// WRONG: path is derived from exportId alone — no tenant namespace
async function saveExport(tenantId, exportId, data) {
// exportId is an integer — predictable, and shared across all tenants
const filePath = path.join(EXPORT_DIR, `${exportId}.json`); // WRONG
await fs.writeFile(filePath, JSON.stringify(data));
return { path: filePath };
// Tenant B can read tenant A's export by guessing exportId
// Tenant B can also overwrite tenant A's export if they know the ID
}
RIGHT — namespace every write under a per-tenant directory and validate the resolved path
import path from 'node:path';
import fs from 'node:fs/promises';
import crypto from 'node:crypto';
const EXPORT_BASE = '/var/lib/mcp-server/exports';
// RIGHT: every tenant gets their own directory; paths are validated before writes
async function saveExport(tenantId, data) {
// Validate tenantId is a known safe format (UUID) before using in a path
if (!/^[0-9a-f-]{36}$/.test(tenantId)) throw new Error('Invalid tenantId');
const tenantDir = path.resolve(EXPORT_BASE, tenantId);
const root = tenantDir + path.sep;
// Ensure the tenant directory exists
await fs.mkdir(tenantDir, { recursive: true, mode: 0o700 });
// Use a random filename — never use a user-supplied or predictable name
const fileName = `${crypto.randomUUID()}.json`;
const filePath = path.resolve(tenantDir, fileName);
// RIGHT: containment check — same pattern as zip slip prevention
if (!filePath.startsWith(root)) {
throw new Error('Path traversal detected in export path');
}
await fs.writeFile(filePath, JSON.stringify(data), { mode: 0o600 });
// Return a reference ID, not the raw path
return { exportRef: `${tenantId}/${fileName}` };
}
async function readExport(tenantId, exportRef) {
if (!/^[0-9a-f-]{36}$/.test(tenantId)) throw new Error('Invalid tenantId');
const tenantDir = path.resolve(EXPORT_BASE, tenantId);
const root = tenantDir + path.sep;
// exportRef is "tenantId/uuid.json" — extract just the filename
const fileName = path.basename(exportRef);
const filePath = path.resolve(tenantDir, fileName);
// RIGHT: verify the resolved path stays inside this tenant's directory
if (!filePath.startsWith(root)) {
throw new Error('Path traversal detected in export reference');
}
return fs.readFile(filePath, 'utf8');
}
How SkillAudit detects multi-tenant security issues
SkillAudit's static analysis identifies multi-tenancy failures by tracing the flow of tenant context — typically a tenantId, orgId, or accountId extracted from the session or JWT — through tool handler code. SQL queries that select, update, or delete rows without a matching tenant predicate on parameterized input are flagged as IDOR risks. Cache key construction expressions that incorporate a resource ID variable but not the tenant context variable are flagged as cache-scoping failures. Error catch blocks that return err.message directly to the caller are flagged because the message may contain data from shared infrastructure layers. These findings map to the Data Isolation and Authorization axes of the SkillAudit grade.
Filesystem path construction calls — path.join or path.resolve — that include user-controlled or resource-ID-derived segments without a prior startsWith containment check against a tenant-namespaced root are flagged as path-traversal or tenant-bleed findings. In practice a single missing AND tenant_id = $2 predicate in a multi-tenant MCP server can expose every other customer's data through a single enumeration loop, making it one of the highest-impact findings in the corpus. Run a free scan at skillaudit.dev to check whether your MCP server enforces tenant boundaries at every data access point.