MCP Multi-tenant Security: Structurally Blocking Inter-tenant Data Leaks with Cloudflare Durable Objects
In early 2025, a single line that quietly appeared in the MCP SDK 1.26.0 patch notes became a hot topic among SaaS developers. It stated that a bug had been discovered where responses were leaked to other clients when a single McpServer instance was shared globally on a stateless MCP server. If actual customer data was exposed to other customers, it would not be a simple bug, but a security incident that simultaneously undermines legal liability and service trust. This incident starkly demonstrates how precarious multi-tenant designs relying on "software isolation" are in protocols like MCP that possess persistent session state.
This article provides an in-depth analysis of how Cloudflare Durable Objects (DO) address the session isolation issues of MCP servers at the design phase. Going beyond a simple "methodology introduction," it examines, with actual code, why existing tenant_id filtering methods are vulnerable to MCP and how DO's runtime isolation model structurally eliminates this vulnerability. After reading this article, you will be equipped with the judgment criteria necessary to design and implement your own MCP servers on Cloudflare Workers that eliminate the risk of cross-tenant data leakage from the structure itself.
Prerequisites: This article is intended for backend and full-stack developers who are familiar with Cloudflare Workers and basic TypeScript syntax, and are interested in MCP or SaaS security. Even if you are new to MCP, sufficient context is provided in the key concepts section.
Cross-Tenant Data Leak: A security vulnerability in which data from one tenant (user/organization) is unintentionally exposed to other tenants in a multi-tenant system. It occurs due to various causes, such as software bugs, incorrect caching, and global state sharing, and leads to legal liability and loss of trust in SaaS services.
Durable Objects' "dedicated instance per tenant" model provides runtime-level isolation rather than software convention, eliminating the possibility of data leakage caused by a single invalid query line from the structure itself.
index
- Key Concepts
- Practical Application
- Pros and Cons Analysis
- Most Common Mistakes in Practice
- In Conclusion
Key Concepts
The Tension Between MCP and Multi-tenant Environments
MCP (Model Context Protocol) is an AI agent-tool integration standard designed by Anthropic that defines JSON-RPC-based communication between a client (LLM runtime) and a server (provider of tools and data). The problem is that the MCP server maintains session state. If a single server instance holds global state, sessions from multiple tenants end up sharing the same memory space.
The traditional multi-tenant approach places a tenant_id column in a single database and filters it in every query. While this method works to some extent when requests are independent (stateless), like REST APIs, in protocols like MCP where session contexts are accumulated and shared between requests, even a single bug can cause tenant boundaries to collapse. The aforementioned MCP SDK 1.26.0 bug is a prime example of this.
Cloudflare Durable Objects: Runtime Units of Isolation
Durable Objects (DOs) are integrated compute and storage stateful units that run on Cloudflare Workers. Each DO instance operates based on V8 Isolate (an independent execution context of the Google V8 engine, where each isolate has its own separate heap memory), and memory access between instances is fundamentally blocked. Cloudflare officially refers to this as "logical isolation." Although the model differs from OS process isolation due to its V8 isolate-based isolation, it provides practical benefits for multi-tenant isolation by fundamentally blocking data access between instances at the code path level.
From the perspective of multi-tenant isolation, the key attributes are the following three.
| Attributes | Description |
|---|---|
| Single Instance Guarantee | Only one DO created with the same ID exists worldwide. Serialization without concurrency conflicts |
| Isolated SQLite Storage | Up to 10GB dedicated SQLite per instance as of April 2025 GA. No way to access from other instances |
| McpAgent 1:1 Correspondence | McpAgent in the Cloudflare Agents SDK creates one DO instance per MCP client connection |
McpAgent and Multiple Sessions: In the idFromName("tenant:tenantId") pattern, multiple tabs or connections from the same tenant are all routed to the same DO instance. Due to the serial processing nature of DOs, these requests are processed sequentially. If session-level isolation is required (e.g., separating each conversation session independently), a design that adds a session ID to the DO name is necessary. However, since this makes state sharing between sessions within the same tenant impossible, it is important to clarify the design intent.
DO vs. Redis + DB Separation Method: Traditional multi-tenancy places a tenant_id column in a single DB and filters for each query. In the DO method, since a separate SQLite exists for each tenant, it is structurally impossible to make the mistake of omitting WHERE tenant_id = ? conditions.
Connection Structure between McpAgent and DO
To summarize the isolation principle in one line: requests routed to idFromName("tenant:A") are processed only within that DO instance, and no code path can reach the memory or storage of the "tenant:B" instance.
클라이언트 세션 (테넌트 A)
│
▼
Cloudflare Worker (진입점)
extractTenantId() → "tenant-a"
│
▼
idFromName("tenant:tenant-a")
│
▼
DO 인스턴스 A ← McpAgent 실행
[전용 SQLite 10GB]
[격리된 V8 Isolate 메모리]
클라이언트 세션 (테넌트 B) → DO 인스턴스 B (완전히 분리)The Worker acts only as a thin router, and the actual MCP logic and state are all encapsulated within the DO instance. No data is shared between DO instances without explicit RPC calls.
Practical Application
The three examples below progressively evolve in the order of Basic Structure → ORM Integration → Authentication Integration. Example 1 introduces a brief form of extractTenantId(), and Example 3 covers the actual JWT-based implementation.
Example 1: Tenant ID-based DO Routing (Basic Structure)
This is the most basic pattern. At the Worker entry point, the Tenant ID is extracted from the request's authentication information and used as the DO instance name. The actual JWT-based implementation of extractTenantId() is covered in Example 3.
// worker.ts — 얇은 라우터 역할
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
// 1. 헤더 또는 JWT에서 테넌트 ID 추출
// 구체적인 JWT 구현은 예시 3 참고
const tenantId = await extractTenantId(request);
if (!tenantId) {
return new Response('Unauthorized', { status: 401 });
}
// 2. 동일 tenantId → 항상 동일 DO 인스턴스
// 다른 tenantId → 런타임 격리된 DO 인스턴스
const id = env.MCP_AGENT.idFromName(`tenant:${tenantId}`);
const stub = env.MCP_AGENT.get(id);
// 3. 모든 처리를 DO에 위임
return stub.fetch(request);
} catch (error) {
console.error('Routing error:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
};
// agent.ts — DO 인스턴스 = 세션 격리 단위
export class TenantMcpAgent extends McpAgent<Env, {}, { tenantId: string }> {
server = new McpServer({ name: 'tenant-mcp', version: '1.0.0' });
async init() {
this.server.tool('getTenantData', {}, async () => {
// this.ctx.storage는 이 DO 인스턴스만의 전용 SQLite
// 어떤 코드도 다른 테넌트의 storage에 접근할 수 없음
const data = await this.ctx.storage.get('sensitive-data');
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
});
}
}| Code Point | Role |
|---|---|
idFromName(\tenant:${tenantId}`)` |
Convert Tenant ID to a DO Unique Identifier. Guarantee Same ID → Same Instance |
stub.fetch(request) |
Worker handles routing only; business logic is encapsulated inside DO |
this.ctx.storage |
SQLite for this DO instance only. Cannot be accessed by other instances |
try/catch (Worker Level) |
Handle routing phase exceptions as 500. Block unauthenticated requests as 401 |
Example 2: Managing Type-Safe Tenant Schemas with Drizzle ORM
While it is possible to directly use the built-in SQLite, using the Drizzle ORM offers two advantages. First, TypeScript type inference can catch column name typos or type mismatches during the compilation phase. Second, schema migrations can be managed via code, allowing for systematic changes to the database structure on a per-tenant basis.
// pnpm add drizzle-orm
// drizzle-orm/durable-sqlite는 drizzle-orm 패키지 내 경로로 별도 설치가 불필요합니다
import { drizzle } from 'drizzle-orm/durable-sqlite';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// 스키마 정의 — tenant_id 컬럼이 필요 없음 (DO 자체가 테넌트 경계)
const userRecords = sqliteTable('user_records', {
id: text('id').primaryKey(),
payload: text('payload').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }),
});
export class TenantDataAgent extends McpAgent {
private db!: ReturnType<typeof drizzle>;
async init() {
// DO의 내장 SQLite를 Drizzle로 래핑
// 이 db 인스턴스는 이 테넌트의 DO에만 존재
this.db = drizzle(this.ctx.storage, { schema: { userRecords } });
this.server.tool('queryRecords', {}, async () => {
// WHERE tenant_id = ? 없이도 안전: 런타임 수준에서 이 테넌트 데이터만 존재
const records = await this.db.select().from(userRecords).all();
return {
content: [{ type: 'text', text: JSON.stringify(records) }]
};
});
this.server.tool('insertRecord', {
id: z.string(),
payload: z.string(),
}, async ({ id, payload }) => {
await this.db.insert(userRecords).values({
id,
payload,
createdAt: new Date(),
});
return { content: [{ type: 'text', text: 'inserted' }] };
});
}
}Crucial difference from existing multi-tenant DBs: In the traditional approach, filter conditions like SELECT * FROM user_records WHERE tenant_id = 'A' are mandatory. If a developer accidentally omits this condition, the entire tenant data is exposed. In a DO-based approach, the tenant_id column itself does not need to exist in this schema. This is because only that tenant data exists in isolation at the runtime level within the SQLite of the corresponding DO.
Example 3: Cloudflare Zero Trust and Access for SaaS Integration
In an enterprise environment, an Identity Provider (IdP) such as Okta or Azure AD is integrated with Cloudflare Access, and the DO instance is determined by the claims of the JWT issued by Access. The example below actually implements extractTenantId() from Example 1.
// auth.ts
// getCloudflareAccessJWT: Cloudflare Access JWT 검증 유틸리티
// 직접 구현하거나 커뮤니티 라이브러리를 활용할 수 있습니다.
// 서명 검증 방법: https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/
async function getCloudflareAccessJWT(
jwt: string
): Promise<Record<string, unknown>> {
// 실제 구현 시 JWKS 엔드포인트에서 공개키를 가져와 서명 검증이 필요합니다
// 아래는 구조 설명을 위한 단순화된 버전입니다
const [, payloadBase64] = jwt.split('.');
return JSON.parse(atob(payloadBase64));
}
export async function extractTenantId(
request: Request
): Promise<string | null> {
try {
// Cloudflare Access가 모든 요청에 자동으로 JWT를 주입
const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
if (!jwt) return null;
const payload = await getCloudflareAccessJWT(jwt);
// JWT 클레임에서 조직 ID 추출 (Okta/Azure AD의 org_id 클레임)
const tenantId =
(payload.org_id as string) ?? (payload.sub as string) ?? null;
return tenantId;
} catch (error) {
// JWT 검증 실패 시 null 반환 → Worker에서 401로 처리
console.error('JWT validation failed:', error);
return null;
}
}# wrangler.toml — 테넌트별 서브도메인 라우팅 설정
# 각 테넌트는 tenant-a.mcp.company.com → DO 인스턴스 "tenant-a"에만 도달합니다
routes = [
{ pattern = "*.mcp.company.com/*", zone_name = "company.com" }
]
[[durable_objects.bindings]]
name = "MCP_AGENT"
class_name = "TenantMcpAgent"By adding MCP Server Portals (open beta in August 2025) to this structure, you can centralize the management of all MCP servers as a single gateway from the Cloudflare Zero Trust dashboard, and centralize tenant-specific SSO, tool access control, and request logging.
Relationship between CVE-2025-6514 and DO Isolation: CVE-2025-6514 is a vulnerability that allows for the manipulation of OAuth metadata in the mcp-remote OAuth proxy. This CVE is a separate attack vector from DO's runtime isolation. Even if DO has blocked data leakage between tenants, this vulnerability in the OAuth handshake process must be patched separately through an SDK upgrade.
Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Strong Isolation Guaranteed | V8 Isolate-based runtime isolation prevents memory and storage sharing between instances |
| Blocking Cross-Tenant Query Errors at the Source | tenant_id Filter Omission Errors Are Structurally Impossible. Tenant Boundaries Are Isolated at the DB Level |
| Global Edge Automated Placement | DO instances are automatically placed in the Cloudflare PoP (global distributed data center) closest to the client |
| Serverless Cost Model | Bill only for active processing time. Zero idle session costs with Hibernation |
| Built-in Session Store | No need for separate Redis or external session stores. DO itself acts as a persistent session store |
| SQLite ACID Transactions | Prevents data inconsistencies caused by partial updates. Guarantees rollback |
Disadvantages and Precautions
Architecture and Performance Considerations
| Item | Content | Response Plan |
|---|---|---|
| Single-threaded processing | A single DO instance serializes concurrent requests. High concurrency within a single session is not possible. | Concurrency within the tenant is handled at the Worker level through a session-based DO design. |
| Cold Start Delay | Wake-up delay of tens of milliseconds occurs on the first request after hibernation | State is preserved via SQLite persistence. Minimal impact on UX |
| Region-Fixed Latency | DOs are fixed to a specific region. Latency increases if the tenant is physically far from that region. | Utilizes Cloudflare Smart Placement (a feature that automatically places DOs in the optimal region based on traffic patterns). |
| Inefficiency in Aggregation Between DOs | Overhead occurs because Workers are required for cross-tenant aggregation operations | A separate Analytics Engine or R2-based pipeline configuration is recommended for cross-tenant aggregation |
Security Vulnerabilities and Patch Notes
| Item | Content | Response Plan |
|---|---|---|
| CVE-2025-6514 | OAuth metadata manipulation vulnerability in mcp-remote OAuth proxy. Attack vector separate from DO isolation | Immediately upgrade Agents SDK to the latest version |
| MCP SDK Legacy Anti-Patterns | The global McpServer instance sharing pattern prior to 1.26.0 poses a risk of response leakage |
Applying Cloudflare's official guidelines to create a new instance for every request |
WebSockets Hibernation: A feature where DO puts inactive sessions into a sleep state. The state is persisted in SQLite, so the session context is restored immediately upon the next request. It is enabled by default in McpAgent, and no charges are incurred for idle sessions.
The Most Common Mistakes in Practice
- Route directly to DO without authentication at the Worker entry point: If
extractTenantId()passesnullwithout processing when it returns it, unauthenticated requests can access arbitrary DO instances. It is important to block this with a 401 response, as in theif (!tenantId) return new Response('Unauthorized', { status: 401 })pattern in Example 1. - Mixing information other than the Tenant ID in DO names: Including the Session ID, as in
idFromName("tenant:A:session:123"), results in hundreds of DOs being generated within the same tenant, complicating state management. It is advisable to use theidFromName("tenant:tenantId")pattern by default and to design a system that includes the Session ID only when session-level isolation is clearly required. In any case, explicitly stating the design intent of the DO name in code comments aids in maintenance. - Missing security patches due to not pinning MCP SDK version: Patches for CVE-2025-6514 or cross-client response leaks will not be applied without an SDK version upgrade. It is recommended to specify a minimum version lower bound in
package.jsonand configure a setup to automatically receive security patch notifications using Dependabot (GitHub's automatic dependency update tool) or Renovate Bot.
In Conclusion
Cloudflare Durable Objects eliminates the risk of cross-tenant leakage at the design phase by separating data into runtime isolation boundaries rather than software conventions in MCP multi-tenant environments. Introducing this isolation model structurally prevents errors such as missing tenant_id filters and significantly reduces the cost of individually verifying whether "isolation logic has been properly applied" during security audits. Even if a multi-tenant incident occurs, the scope of impact is limited to the DO instances of the relevant tenant, allowing for the expectation of a narrower incident response scope.
3 Steps to Start Right Now:
- Configuring Basic McpAgent with Cloudflare Agents SDK: After creating a starter project with
pnpm create cloudflare@latest -- --template=cloudflare/agents-starter, you can check the DO binding by adding the[[durable_objects.bindings]]setting towrangler.toml. - Applying Tenant Routing Logic: If you write the
extractTenantId()function at the Worker entry point and implement routing using theidFromName(\tenant:${tenantId}`)` pattern, you can directly verify on the Cloudflare dashboard that separate DO instances are created for each tenant. - Type-safe SQLite schema with Drizzle ORM: By installing
pnpm add drizzle-ormand wrapping the DO-embedded SQLite with Drizzle usingdrizzle(this.ctx.storage, { schema }), you can manage type-safe queries and schema migrations.
Next Article: We will cover how to combine Cloudflare Gateway DLP and OPA (Open Policy Agent) to manage granular RBAC policies per MCP tool in code and implement real-time sensitive data leakage detection.
Reference Materials
Cloudflare Official Documentation
- Piecing together the Agent puzzle: MCP, authentication & authorization, and Durable Objects free tier | Cloudflare Blog
- Build and deploy Remote MCP servers to Cloudflare | Cloudflare Blog
- McpAgent API Reference | Cloudflare Agents docs
- Build a Remote MCP server | Cloudflare Agents docs
- Securing MCP servers | Cloudflare Agents docs
- Zero-latency SQLite storage in every Durable Object | Cloudflare Blog
- SQLite in Durable Objects GA with 10GB storage per object | Cloudflare Changelog
- Securing the AI Revolution: Introducing Cloudflare MCP Server Portals | Cloudflare Blog
- Secure MCP servers with Access for SaaS | Cloudflare One docs
- Durable Objects in Dynamic Workers: Give each AI-generated app its own database | Cloudflare Blog
- Rules of Durable Objects | Cloudflare Durable Objects docs
- Agents SDK v0.4.0 release notes | Cloudflare Community
Community & Case Studies