Security · Multi-tenant
MCP server privilege escalation
Privilege escalation in an MCP server happens when a caller can access resources or invoke actions beyond what their authentication level permits. In single-tenant servers this usually means an unauthenticated path to a protected tool. In multi-tenant servers — where the same MCP endpoint serves multiple organizations or users — it means caller A reading or modifying caller B's data by supplying B's resource IDs in tool arguments. Prompt-injected AI agents exacerbate this: an LLM that has been given a user's API key and a malicious prompt can attempt to call administrative tools it was never intended to reach. This guide covers the patterns that prevent privilege escalation at the MCP layer, independent of the application database's access controls.
TL;DR
Bind a caller context (authenticated identity, tenant ID, role, and granted scopes) to every MCP session at connection time, then thread that context into every tool handler. Never trust a resource ID in a tool argument alone — always verify that the resource belongs to the caller's tenant before returning it. Use a middleware that checks scope grants before the handler runs, and return isError: true with a 403-equivalent message rather than throwing, so the error is visible in the audit log. Test privilege escalation explicitly: write a test that calls a tool with a valid resource ID from a different tenant and asserts the handler returns a "not found" or "access denied" error, not the resource. Monitor abnormal cross-tool call patterns with AliveMCP's uptime monitoring and your audit log.
Caller context binding
The root cause of most MCP privilege escalation bugs is that the tool handler reads the caller's identity from a module-level variable or request state that can bleed between concurrent sessions. The safe pattern is to bind the caller context at authentication time and close over it in the tool handler function, so each session has its own private context that cannot be modified by another session:
interface CallerContext {
callerId: string;
tenantId: string;
role: 'admin' | 'member' | 'viewer';
scopes: Set<string>; // e.g. Set(['read:files', 'write:files', 'admin:billing'])
}
// Called once per MCP session after authentication succeeds
function createSessionHandlers(ctx: CallerContext) {
// ctx is closed over — each session has its own private copy
// No module-level state is shared between sessions
return {
read_file: async ({ path }: { path: string }) => {
if (!ctx.scopes.has('read:files')) {
return { content: [{ type: 'text', text: 'Access denied: read:files scope required' }], isError: true };
}
// Scope check passed — now verify resource ownership
const file = await db.prepare(
'SELECT * FROM files WHERE path = ? AND tenant_id = ?'
).get(path, ctx.tenantId); // <-- tenant_id from ctx, NOT from the tool argument
if (!file) {
return { content: [{ type: 'text', text: 'File not found' }], isError: true };
}
return { content: [{ type: 'text', text: file.content }] };
},
delete_user: async ({ user_id }: { user_id: string }) => {
if (ctx.role !== 'admin') {
return { content: [{ type: 'text', text: 'Access denied: admin role required' }], isError: true };
}
// Admin check passed — still verify the target user belongs to this tenant
const user = await db.prepare(
'SELECT id FROM users WHERE id = ? AND tenant_id = ?'
).get(user_id, ctx.tenantId);
if (!user) {
return { content: [{ type: 'text', text: 'User not found' }], isError: true }; // not "access denied" — leaks nothing
}
await db.prepare('DELETE FROM users WHERE id = ?').run(user_id);
return { content: [{ type: 'text', text: `User ${user_id} deleted` }] };
},
};
}
// In your MCP session setup:
app.post('/mcp', async (req, res) => {
const ctx = await authenticate(req); // throws if auth fails
const handlers = createSessionHandlers(ctx); // context bound here, not at tool-call time
// Register handlers with MCP server for this session
});
Resource ownership checks
Even with correct scope enforcement, privilege escalation is possible if resource lookups don't filter by tenant. A caller with read:documents scope could supply a document ID belonging to another tenant and receive that tenant's document if the query doesn't include a tenant filter:
// WRONG: looks up document by ID alone — caller can read any tenant's document
// by guessing or discovering IDs (e.g., UUIDs from a shared-sequence generator)
server.tool('get_document', 'Get document by ID', { id: z.string().uuid() }, async ({ id }) => {
const doc = await db.get('SELECT * FROM documents WHERE id = ?', id); // MISSING TENANT FILTER
if (!doc) return { content: [{ type: 'text', text: 'Not found' }], isError: true };
return { content: [{ type: 'text', text: doc.content }] };
});
// RIGHT: always filter by both resource ID and caller's tenant ID
server.tool('get_document', 'Get document by ID', { id: z.string().uuid() }, async ({ id }) => {
const doc = await db.get(
'SELECT * FROM documents WHERE id = ? AND tenant_id = ?',
id, ctx.tenantId // ctx.tenantId comes from authentication, not from tool args
);
// If the document belongs to a different tenant, the query returns null —
// same response as "not found". Don't say "access denied" — that confirms the ID exists.
if (!doc) return { content: [{ type: 'text', text: 'Document not found' }], isError: true };
return { content: [{ type: 'text', text: doc.content }] };
});
The "not found" response for a cross-tenant access attempt is intentional — returning "access denied" confirms to the caller that the resource exists under a different tenant, which is information disclosure. "Not found" is the correct response regardless of whether the ID doesn't exist or belongs to a different tenant.
Scope-gated tool registration
For a large tool catalog, adding scope checks to every handler is repetitive and error-prone. A middleware wrapper that enforces scope before calling the handler is cleaner:
function scopeGated<T extends Record<string, z.ZodType>>(
requiredScope: string,
ctx: CallerContext,
handler: (args: any) => Promise<any>
) {
return async (args: any) => {
if (!ctx.scopes.has(requiredScope)) {
return {
content: [{ type: 'text', text: `Access denied: ${requiredScope} scope required` }],
isError: true,
};
}
return handler(args);
};
}
// Usage: wrap every tool handler with scopeGated
const handlers = createSessionHandlers(ctx);
server.tool('read_file', 'Read a file', { path: filePathSchema },
scopeGated('read:files', ctx, handlers.read_file));
server.tool('write_file', 'Write a file', { path: filePathSchema, content: z.string() },
scopeGated('write:files', ctx, handlers.write_file));
server.tool('delete_user', 'Delete a user (admin only)', { user_id: z.string().uuid() },
scopeGated('admin:users', ctx, handlers.delete_user));
This pattern ensures every tool has a declared required scope, and the scope check is enforced consistently by the middleware rather than remembered by the developer for each handler. The scope declaration also serves as documentation — a reader can see at a glance which tools require which scopes.
Indirect object reference attacks
Indirect object reference attacks (a type of IDOR — Insecure Direct Object Reference) happen when a caller can enumerate or guess other users' resource IDs. Use opaque, unguessable IDs and avoid predictable sequences:
// WEAK: sequential integer IDs — caller can enumerate other tenants' resources
// by trying id=1, id=2, id=3, ...
await db.run('INSERT INTO documents (tenant_id, content) VALUES (?, ?)', [tenantId, content]);
// Returns: { id: 1043 } — predictable, enumerable
// STRONG: UUID v4 — 122 bits of randomness, effectively unguessable
import { randomUUID } from 'crypto';
const docId = randomUUID();
await db.run('INSERT INTO documents (id, tenant_id, content) VALUES (?, ?, ?)', [docId, tenantId, content]);
// Returns: { id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' } — not enumerable
// Even with UUIDs, always verify tenant ownership on every read —
// a UUID leak from a log or error message could still enable cross-tenant access
Opaque IDs reduce the attack surface but don't eliminate ownership checks — a UUID can be leaked through error messages, logs, or shared links. Always verify ownership at the database layer, not just at the input validation layer.
Testing privilege escalation
Privilege escalation bugs are easy to miss in manual testing because they require thinking from the attacker's perspective. Automate cross-tenant access tests as part of your test suite:
import { describe, it, assert } from 'node:test';
describe('privilege escalation guards', () => {
it('cannot read another tenant\\'s document via its ID', async () => {
// Setup: create a document as tenant A
const tenantADoc = await createDocument({ tenantId: 'tenant-a', content: 'secret data' });
// Attempt: authenticated as tenant B, try to read tenant A's document
const result = await callTool('get_document',
{ id: tenantADoc.id },
{ callerId: 'user-b', tenantId: 'tenant-b', scopes: new Set(['read:documents']) }
);
assert.ok(result.isError, 'should return an error');
assert.ok(!JSON.stringify(result).includes('secret data'), 'should not return the document content');
});
it('viewer role cannot call an admin tool', async () => {
const result = await callTool('delete_user',
{ user_id: 'some-user' },
{ callerId: 'viewer-1', tenantId: 'tenant-a', role: 'viewer', scopes: new Set(['read:users']) }
);
assert.ok(result.isError);
assert.match(result.content[0].text, /access denied/i);
});
it('cannot escalate by providing own tenant_id in tool args', async () => {
// Some servers expose tenant_id as a tool argument — this must be ignored
// and replaced with ctx.tenantId from authentication
const result = await callTool('list_users',
{ tenant_id: 'admin-tenant' }, // attacker tries to escalate via arg
{ callerId: 'attacker', tenantId: 'tenant-evil', scopes: new Set(['read:users']) }
);
// Should return tenant-evil's users, not admin-tenant's users
const users = JSON.parse(result.content[0].text);
assert.ok(users.every((u: any) => u.tenant_id === 'tenant-evil'));
});
});
Related questions
What's the difference between authentication and authorization in MCP servers?
Authentication answers "who is this caller?" — verifying their identity via API key, JWT, or OAuth token. Authorization answers "what can this caller do?" — checking that the authenticated caller has permission to invoke the requested tool with the requested arguments. Both are required. Authentication without authorization means any authenticated caller can do anything. Authorization without authentication means you're checking permissions for an untrusted identity claim. The caller context pattern in this guide does both: authentication happens at connection time, authorization (scope and ownership checks) happens in each tool handler.
Should I use database-level row security or application-level tenant checks?
Both, ideally — defense in depth. PostgreSQL Row Level Security (RLS) and SQLite's lack of it illustrate the tradeoff: RLS enforces tenant isolation at the database level even if the application layer has a bug, but it requires setting a session variable before each query and can be complex to maintain. Application-level tenant checks (always adding AND tenant_id = ? to queries) are simpler and more portable but rely on developer discipline. If you use SQLite (the default for MCP servers per TOOLBOX.md), application-level checks are your only option — SQLite has no RLS. Make them systematic with query helper functions that always include the tenant filter.
How do prompt-injected LLMs escalate MCP server privileges?
A prompt-injected LLM might attempt to call tools it wasn't supposed to call (by discovering the tool list via tools/list), call tools with forged resource IDs (found in the conversation context), or claim elevated permissions by modifying its system prompt. MCP servers should not trust the LLM's claimed identity or scope — trust only the API key or JWT presented in the HTTP request headers. The LLM is a caller, not an authority. If the LLM sends a tool call claiming admin scope, your server checks the authenticated credential's scope, not the LLM's claim. See MCP server input sanitization for prompt injection detection at the tool argument level.
Further reading
- MCP server RBAC — role-based access control and scope definitions
- MCP server authentication — API key, JWT, and OAuth token validation
- MCP server audit logging — recording and alerting on access attempts
- MCP server input sanitization — blocking injection at the protocol boundary
- MCP server multi-tenant — data isolation patterns and session management
- AliveMCP — external monitoring for MCP servers: detect unusual outage patterns caused by security incidents