Explore our comprehensive resources on behavioral AI monitoring, compliance frameworks, and policy templates.
Start your compliance journey with HAIEC. Free assessment, automated evidence, audit-ready documentation.
Explore compliance frameworks:
Developer tools & integrations:
How AI AppSec uses Semgrep to detect prompt injection sinks, tool abuse, and AI-specific vulnerabilities in source code. 122 detectors, 79 security checks, with code examples.
How @haiec/openai and @haiec/anthropic wrap the official SDKs to capture model usage, token counts, and costs automatically. The instrumentation pattern and privacy considerations.
Learn what AI vendor public security disclosures entail and how they impact AI security and compliance professionals.
A SaaS company I worked with had a bug report from a customer: "I can see documents from another organization in my search results." The engineering team spent two days tracing the issue. It turned out to be a single Prisma query in a search handler that forgot to include organizationId in the where clause. The query was in a code path added three months earlier, tested by the team, and merged without anyone noticing the missing filter.
Cross-tenant data leakage is not exotic. It is the most common and most damaging vulnerability in multi-tenant SaaS. It happens because tenant context is easy to forget in one code path, one async boundary, one error handler. General-purpose security scanners catch SQL injection and XSS. They do not check whether your Prisma findMany includes an organizationId filter.
MCP Tenant Isolation is a static analysis scanner with 57 deterministic rules that check exactly this. 42 rules cover general multi-tenant patterns. 15 rules cover MCP-specific patterns. This article walks through the most common leakage patterns, the vulnerable code, the fix, and the rule that catches each one.
The 42 general multi-tenant rules are organized into six categories:
| Prefix | Category | Rules | What it checks | |---|---|---|---| | TCM | Tenant Context Management | 6 | Tenant ID source, async propagation, error handling | | DBQ | Database Query Isolation | 10 | Missing tenant filters, raw queries, RLS detection | | CCH | Cache Key Isolation | 5 | Cache keys without tenant prefix | | FIL | Filesystem Isolation | 5 | File access without tenant-scoped root | | ART | Artifact Storage Isolation | 5 | S3, blob storage without tenant prefix in key | | API | API Response Scoping | 11 | Missing tenant scoping in API responses, rate limits |
The 15 MCP-specific rules cover tool visibility, session binding, credential vault isolation, and MCP transport security.
This is the most common pattern. A database query that does not include the tenant filter in its where clause.
// Vulnerable: no organizationId filter
async function searchDocuments(query: string) {
return await prisma.documents.findMany({
where: { content: { contains: query } },
})
}
Every tenant's documents are searched. The fix:
// Fixed: organizationId from session context
async function searchDocuments(query: string, organizationId: string) {
return await prisma.documents.findMany({
where: { organizationId, content: { contains: query } },
})
}
Rule DBQ-001 detects Prisma findMany, findFirst, findUnique, and updateMany calls where the where clause does not include a tenant identifier field (organizationId, tenantId, customerId).
Redis and in-memory caches are shared across tenants by default. If the cache key does not include the tenant ID, Tenant A's cached data is served to Tenant B.
// Vulnerable: cache key has no tenant prefix
async function getUserPreferences(userId: string) {
const cacheKey = `prefs:${userId}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const prefs = await fetchPreferences(userId)
await redis.set(cacheKey, JSON.stringify(prefs), 'EX', 3600)
return prefs
}
If Tenant A's user has ID 123 and Tenant B's user also has ID 123 (common when user IDs are per-tenant), Tenant B gets Tenant A's preferences.
The fix:
// Fixed: tenant prefix in cache key
async function getUserPreferences(userId: string, tenantId: string) {
const cacheKey = `tenant:${tenantId}:prefs:${userId}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const prefs = await fetchPreferences(userId)
await redis.set(cacheKey, JSON.stringify(prefs), 'EX', 3600)
return prefs
}
Rule CCH-001 detects cache get, set, and delete calls where the key is constructed without a tenant identifier. The rule checks for redis, ioredis, node-cache, and lru-cache patterns.
Node.js async boundaries (callbacks, promises, event handlers) do not automatically propagate AsyncLocalStorage. If you set tenant context in a request handler and then pass work to a background job, the tenant context is gone.
// Vulnerable: tenant context lost in queue handler
app.post('/api/documents', async (req, res) => {
const tenantId = req.user.organizationId
await documentQueue.add('process', { documentId: req.body.id })
res.json({ status: 'queued' })
})
// In the queue worker — no tenantId available
documentQueue.process('process', async (job) => {
const doc = await prisma.documents.findUnique({
where: { id: job.data.documentId },
})
// doc belongs to any tenant — no scoping
await processDocument(doc)
})
The queue worker has no access to tenantId. It fetches the document by ID without tenant scoping. If an attacker knows another tenant's document ID, they can queue a job to process it.
The fix: pass tenant context explicitly in the job data and use it in the worker.
// Fixed: tenant context passed in job data
app.post('/api/documents', async (req, res) => {
const tenantId = req.user.organizationId
await documentQueue.add('process', {
documentId: req.body.id,
tenantId, // Explicit tenant context
})
res.json({ status: 'queued' })
})
documentQueue.process('process', async (job) => {
const { documentId, tenantId } = job.data
const doc = await prisma.documents.findFirst({
where: { id: documentId, organizationId: tenantId }, // Scoped
})
if (!doc) return // Not found in this tenant
await processDocument(doc)
})
Rule TCM-002 detects queue handlers, event listeners, and setTimeout/setInterval callbacks that access database models without tenant context propagation. The rule also checks for AsyncLocalStorage usage and flags cases where it is not used for tenant context.
File storage that does not scope paths by tenant allows path traversal and cross-tenant file access.
// Vulnerable: no tenant scoping in file path
app.get('/files/:filename', async (req, res) => {
const filePath = path.join(uploadDir, req.params.filename)
const content = await fs.readFile(filePath)
res.send(content)
})
A user can request ../../../other-tenant/private.txt and read another tenant's files.
The fix:
// Fixed: tenant-scoped root directory
app.get('/files/:filename', async (req, res) => {
const tenantDir = path.join(uploadDir, req.user.organizationId)
const filePath = path.join(tenantDir, req.params.filename)
// Verify the resolved path is still inside tenantDir
const resolved = path.resolve(filePath)
if (!resolved.startsWith(path.resolve(tenantDir) + path.sep)) {
return res.status(403).send('Invalid path')
}
const content = await fs.readFile(resolved)
res.send(content)
})
Rule FIL-001 detects file read/write operations where the path is constructed without a tenant identifier. The rule also checks for missing path traversal protection (the startsWith check in the fix above).
MCP servers that register tools without tenant namespace in the tool name expose all tools to all tenants.
// Vulnerable: tool registered without tenant scoping
server.tool("admin_settings", {}, async () => {
return { content: [{ type: "text", text: JSON.stringify(systemSettings) }] }
})
Every tenant's LLM can call admin_settings. There is no per-tenant tool visibility.
The fix depends on your architecture. If tools are per-tenant:
// Fixed: tenant-scoped tool registration
server.tool(`tenant_${tenantId}_admin_settings`, {}, async () => {
return { content: [{ type: "text", text: JSON.stringify(tenantSettings) }] }
})
If tools are global but access is controlled:
// Fixed: access check in tool handler
server.tool("admin_settings", {}, async () => {
if (!hasAdminAccess(session.tenantId)) {
return { content: [{ type: "text", text: "Access denied" }], isError: true }
}
return { content: [{ type: "text", text: JSON.stringify(systemSettings) }] }
})
Rule MCP-001 detects MCP tool registrations where the tool name does not include a tenant identifier and the handler does not perform a tenant access check. The rule recognizes the MCP v2 SDK server.tool registration pattern.
Runtime tests check specific code paths with specific inputs. If you test the search handler with Tenant A's data, it works. You never test it with Tenant B's data because you do not know to try. The vulnerability only manifests when Tenant B's data exists in the same database.
Static analysis reads every code path. It checks every Prisma query, every cache access, every file operation. It does not need test data from multiple tenants. It checks the source code structure and flags patterns that indicate missing tenant scoping.
This is why MCP Tenant Isolation has 57 rules and 203 tests. The rules are deterministic — same input, same output. No AI, no heuristics. Every finding includes the rule ID, file path, line number, and a fix suggestion.
| Category | Rules | Coverage | |---|---|---| | TCM (Tenant Context Management) | 6 | Tenant ID source, async propagation, error handling, queue handlers | | DBQ (Database Query Isolation) | 10 | Prisma, raw SQL, RLS detection, migration analysis | | CCH (Cache Key Isolation) | 5 | Redis, in-memory, CDN, LRU cache | | FIL (Filesystem Isolation) | 5 | Path scoping, traversal protection, symlink detection | | ART (Artifact Storage Isolation) | 5 | S3, blob storage, object key construction | | API (API Response Scoping) | 11 | Response metadata, rate limiting, pagination, error responses | | MCP (MCP-Specific) | 15 | Tool visibility, session binding, credential vault, transport security | | Total | 57 | |
npm install -g mcp-tenant-isolation
mti scan ./src --format sarif --output results.sarif
The scanner outputs SARIF 2.1.0 (GitHub Code Scanning compatible), JSON, terminal (human-readable), and MCP structured output for AI agent consumption.
MCP server integration for AI agents:
{
"mcpServers": {
"mcp-tenant-isolation": {
"command": "npx",
"args": ["-y", "mcp-tenant-isolation"]
}
}
}
The MCP server exposes four tools: scan_tenant_isolation, list_tenant_isolation_rules, explain_tenant_isolation_rule, and suppress_tenant_isolation_finding.
MCP Tenant Isolation is a static analysis tool for tenant boundary code. It does not:
It is one layer in the Developer Security family. The other two layers — AI AppSec for source code and LLMVerify for runtime I/O — catch what this tool cannot.