Designing a Secure MCP Server: Authentication, Tenant Isolation, and Transport Hardening for Production Model Context Protocol Integrations
MCP explicitly defers security to the implementer. That means every registered tool is an authorization boundary you own. This guide covers authentication patterns, tenant isolation strategies that prevent Asana-class data leakage, input validation, rate limiting, and transport hardening for production MCP deployments.
A recent analysis of publicly available MCP servers found that 66% contain code smells and 14.4% carry identifiable bug patterns. Those numbers would concern you in any server-side codebase. In a protocol specifically designed to give AI agents access to your data and tools, they should make you stop and think carefully before shipping anything to production.
The root cause is architectural. Cloudflare’s developer documentation describes MCP as a “transport, not governance” layer. The protocol defines how a client and server exchange messages, how tools are described, and how results flow back. It does not define authentication. It does not define authorization. It does not define tenant isolation. Those are explicitly deferred to the implementer.
That is a reasonable design choice for a protocol. It is a dangerous default for an engineering team that ships quickly and assumes the framework handles security. The Asana breach, where a misconfigured MCP integration allowed one enterprise tenant’s agent to retrieve data belonging to a different tenant, and the WordPress incident affecting over 100,000 sites, both followed the same pattern: teams treated MCP as a black box and did not implement the governance layer the protocol intentionally omits.
This article covers how to build that governance layer.
The Attack Surface
Before writing a line of code, it helps to name the specific threats.
SSRF via tool calls is the most underestimated. When your MCP server exposes a tool that fetches a URL, an attacker who controls the input can direct that fetch to internal services: metadata endpoints at 169.254.169.254, internal Redis instances, or other services on your VPC that are not reachable from the public internet. The MCP server becomes a proxy into your private network.
Prompt injection through tool responses is the attack vector that does not exist in traditional server-side code. When your tool fetches content from an external source and returns it verbatim in the tool response, a malicious website can embed instructions in that content that your downstream LLM will interpret as system-level directives. The tool result is not data to the model; it is text in the context window.
Tenant data leakage is the Asana-class failure. An MCP server that resolves tenant context from the tool arguments rather than from the authenticated session will serve the wrong tenant’s data whenever an agent passes the wrong tenant identifier, whether through misconfiguration or deliberate manipulation.
Transport-layer gaps include replay attacks (re-sending a valid signed request to trigger a tool call multiple times), missing mutual authentication on remote connections, and the specific liability that Cloudflare identified for locally-deployed MCP: a locally running MCP server that lacks request authentication can be accessed by any process on the same host, including malicious code running in the same environment as the agent.
Authentication Patterns
MCP servers can operate in two transport modes: local (stdio, same-process or same-host) and remote (HTTP). The security requirements differ significantly.
For remote MCP servers, OAuth 2.1 with the authorization code flow is the right choice when users authorize agents to act on their behalf. The client presents a bearer token with each tool call, and the server validates it against the authorization server’s JWKS endpoint.
import { createRemoteJWKSet, jwtVerify } from "jose";
interface MCPAuthContext {
tenantId: string;
userId: string;
scopes: string[];
sessionId: string;
}
const JWKS = createRemoteJWKSet(
new URL(`${process.env.AUTH_SERVER_URL}/.well-known/jwks.json`)
);
async function verifyBearerToken(
authorizationHeader: string | undefined
): Promise<MCPAuthContext> {
if (!authorizationHeader?.startsWith("Bearer ")) {
throw new Error("Missing or malformed Authorization header");
}
const token = authorizationHeader.slice(7);
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.AUTH_SERVER_URL,
audience: process.env.MCP_SERVER_AUDIENCE,
algorithms: ["RS256"],
});
const tenantId = payload["tenant_id"] as string | undefined;
const userId = payload["sub"] as string | undefined;
const scopes = ((payload["scope"] as string) ?? "").split(" ");
if (!tenantId || !userId) {
throw new Error("Token missing required claims: tenant_id, sub");
}
return { tenantId, userId, scopes, sessionId: payload["jti"] as string };
}
The tenant_id claim is load-bearing. Every downstream data access uses this value, sourced from the verified token, never from the tool arguments. This is the single rule that prevents Asana-class tenant leakage.
For service-to-service MCP integrations where no user is involved, mTLS is a better fit than OAuth. The client certificate encodes the service identity, and the server validates it against a CA it controls. The mutual verification eliminates the need for a separate token exchange, and the certificate cannot be forged without access to the private key.
For simpler cases, API keys work if you implement them correctly: 32 bytes of crypto.randomBytes encoded as hex, SHA-256 hashed before storage (not bcrypt, which is too slow for per-request verification of high-entropy secrets), and rotatable without downtime. Keep a previous_key_hash column and accept both the current and previous key during a rotation window. The transition window should be no longer than 24 hours.
Transport Hardening: Request Signing
Even with token authentication, you need replay protection. A valid bearer token does not expire for minutes or hours. An attacker who captures a request can re-send it repeatedly within that window.
The fix is HMAC request signing with a short replay window.
import { createHmac, timingSafeEqual } from "crypto";
interface SignedRequest {
body: string;
timestamp: string;
nonce: string;
signature: string;
}
const MAX_AGE_MS = 30_000; // 30 seconds
const usedNonces = new Set<string>(); // Redis in multi-instance deployments
function verifyRequestSignature(
signingSecret: string,
request: SignedRequest
): void {
const now = Date.now();
const requestTime = parseInt(request.timestamp, 10);
if (Math.abs(now - requestTime) > MAX_AGE_MS) {
throw new Error(`Request timestamp outside acceptable window: ${request.timestamp}`);
}
if (usedNonces.has(request.nonce)) {
throw new Error(`Nonce already used: ${request.nonce}`);
}
const payload = `${request.timestamp}.${request.nonce}.${request.body}`;
const expected = createHmac("sha256", signingSecret)
.update(payload)
.digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
const actualBuffer = Buffer.from(request.signature, "hex");
if (
expectedBuffer.length !== actualBuffer.length ||
!timingSafeEqual(expectedBuffer, actualBuffer)
) {
throw new Error("Invalid request signature");
}
usedNonces.add(request.nonce);
// Nonce TTL cleanup: in production, use Redis with a 60s expiry
}
The timingSafeEqual comparison is not optional. A string equality check leaks timing information that allows an attacker to iteratively reconstruct the correct signature one byte at a time. The 30-second replay window is narrow enough to block most replay attacks while providing enough slack for clock skew between client and server.
In multi-instance deployments, the nonce store must be external. An in-process Set cannot be shared across instances, and an attacker who gets load-balanced to a different instance can replay freely. Use Redis with a 60-second TTL on nonce keys.
Tenant Isolation: The Tool Registry Pattern
The Asana breach happened because tenant context was resolved from tool arguments. The fix is architectural: the tenant registry resolves allowed tools at authentication time, and all subsequent tool dispatches operate within that pre-resolved scope.
interface TenantToolGrant {
toolName: string;
requiredScopes: string[];
}
interface TenantContext {
tenantId: string;
userId: string;
grantedScopes: string[];
allowedTools: TenantToolGrant[];
}
class TenantScopedToolRegistry {
private toolDefinitions: Map<string, ToolDefinition> = new Map();
private tenantGrants: Map<string, TenantToolGrant[]> = new Map();
registerTool(definition: ToolDefinition, requiredScopes: string[]): void {
this.toolDefinitions.set(definition.name, definition);
// Default: no tenant has access until explicitly granted
}
grantToolsToTenant(tenantId: string, grants: TenantToolGrant[]): void {
this.tenantGrants.set(tenantId, grants);
}
listToolsForTenant(context: MCPAuthContext): ToolDefinition[] {
const grants = this.tenantGrants.get(context.tenantId) ?? [];
const grantedScopes = new Set(context.scopes);
return grants
.filter((grant) =>
grant.requiredScopes.every((s) => grantedScopes.has(s))
)
.map((grant) => this.toolDefinitions.get(grant.toolName))
.filter((def): def is ToolDefinition => def !== undefined);
}
}
class ScopedToolExecutor {
constructor(private registry: TenantScopedToolRegistry) {}
async execute(
toolName: string,
args: unknown,
context: MCPAuthContext
): Promise<ToolResult> {
// Resolve from the registry using context from the verified token,
// never from tool arguments
const allowedTools = this.registry.listToolsForTenant(context);
const tool = allowedTools.find((t) => t.name === toolName);
if (!tool) {
// Return not-found, not unauthorized, to avoid tool name disclosure
throw new ToolNotFoundError(`Tool not found: ${toolName}`);
}
const validatedArgs = tool.inputSchema.parse(args);
return tool.handler(validatedArgs, context);
}
}
Two design decisions here deserve explicit attention.
First: listToolsForTenant is called with MCPAuthContext, which is populated from the verified JWT token. The tenant identifier cannot be passed in from outside the authentication layer. An agent that tries to call a tool on behalf of a different tenant by passing a different tenant ID in the tool arguments will simply see the list of tools appropriate for the authenticated tenant.
Second: when toolName does not match any allowed tool, the error is ToolNotFoundError, not an authorization error. Returning a 403 on an unauthorized tool call tells the caller that the tool exists but is restricted. Returning a 404 treats all unauthorized and nonexistent tool names identically, preventing tool enumeration by a compromised agent.
Input Validation and SSRF Prevention
Every tool call argument must be validated before execution. Use Zod for schema enforcement and fail closed on invalid input.
import { z } from "zod";
// Allowlist of URL prefixes the fetch tool is permitted to reach
const ALLOWED_URL_PREFIXES = (
process.env.ALLOWED_FETCH_URLS ?? ""
).split(",").filter(Boolean);
const BLOCKED_HOSTS = [
"169.254.169.254", // AWS/GCP/Azure instance metadata
"metadata.google.internal",
"localhost",
"127.0.0.1",
"::1",
];
function isAllowedUrl(rawUrl: string): boolean {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return false;
}
// Block RFC 1918 and loopback ranges
const host = parsed.hostname;
if (BLOCKED_HOSTS.includes(host)) return false;
// Block private IPv4 ranges
const ipv4Private =
/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host);
if (ipv4Private) return false;
// Require match against explicit allowlist
return ALLOWED_URL_PREFIXES.some((prefix) => rawUrl.startsWith(prefix));
}
const FetchToolSchema = z.object({
url: z.string().url().refine(isAllowedUrl, {
message: "URL is not on the allowed list",
}),
method: z.enum(["GET", "HEAD"]).default("GET"),
});
function sanitizeToolOutput(output: string): string {
// Strip patterns that commonly carry prompt injection payloads
return output
.replace(/\[INST\].*?\[\/INST\]/gs, "")
.replace(/<\|im_start\|>.*?<\|im_end\|>/gs, "")
.replace(/###\s*(System|Human|Assistant):/gi, "")
.replace(/IGNORE PREVIOUS INSTRUCTIONS/gi, "[filtered]")
.replace(/You are now/gi, "[filtered]");
}
The allowlist approach for URL validation is safer than a blocklist. Blocklists require you to enumerate every dangerous destination in advance. Allowlists require you to explicitly opt in to each permitted destination. A new internal service that gets added to your VPC is not automatically reachable through your MCP server’s fetch tool, because it was never added to the allowlist.
The sanitizeToolOutput function is a best-effort filter, not a complete defense. The correct long-term strategy is to treat all tool output as untrusted data and configure your LLM system prompts to be skeptical of instruction-like patterns in tool results. The sanitization layer catches common injection templates, but a determined attacker can encode instructions in ways that evade pattern matching. Defense in depth is the point: the output sanitizer reduces the attack surface; the system prompt posture provides the backstop.
Rate Limiting at the MCP Layer
Without rate limiting, a runaway agent or a compromised token can exhaust your downstream tool call budgets, your upstream API quotas, and your LLM token budget simultaneously. Rate limiting at the MCP server layer prevents all three.
import { Redis } from "ioredis";
interface RateLimitConfig {
perTenantPerMinute: number;
perUserPerMinute: number;
perToolPerTenantPerMinute: number;
}
class MCPRateLimiter {
constructor(
private redis: Redis,
private config: RateLimitConfig
) {}
async check(context: MCPAuthContext, toolName: string): Promise<void> {
const windowSeconds = 60;
const now = Math.floor(Date.now() / 1000);
const window = Math.floor(now / windowSeconds);
const checks: Array<{ key: string; limit: number }> = [
{
key: `rl:tenant:${context.tenantId}:${window}`,
limit: this.config.perTenantPerMinute,
},
{
key: `rl:user:${context.userId}:${window}`,
limit: this.config.perUserPerMinute,
},
{
key: `rl:tool:${context.tenantId}:${toolName}:${window}`,
limit: this.config.perToolPerTenantPerMinute,
},
];
const pipeline = this.redis.pipeline();
for (const { key } of checks) {
pipeline.incr(key);
pipeline.expire(key, windowSeconds * 2);
}
const results = await pipeline.exec();
if (!results) throw new Error("Rate limiter pipeline failed");
for (let i = 0; i < checks.length; i++) {
const count = results[i * 2][1] as number;
if (count > checks[i].limit) {
throw new RateLimitExceededError(
`Rate limit exceeded for key pattern: ${checks[i].key}`
);
}
}
}
}
Three separate counters run in a single pipeline: one per tenant (protects against one tenant’s agents flooding the server), one per user (protects against a single compromised token), and one per tool per tenant (protects against a tenant hammering one expensive tool while staying under the aggregate limit). Each counter has a TTL of two windows to handle the edge case where a key is incremented near the end of a window and the expiry races with the next window boundary.
Tradeoffs
| Decision | Option A | Option B | When to choose A |
|---|---|---|---|
| Token auth vs mTLS | OAuth 2.1 bearer tokens | mTLS client certificates | Bearer tokens when users authorize agents; mTLS for service-to-service with no user in the loop |
| URL validation | Explicit allowlist | Blocklist of private ranges | Allowlist: safer by default; new internal services are not automatically reachable |
| Tenant context source | Verified JWT claims only | JWT claim with tool-arg override | JWT only: eliminates an entire class of tenant leakage. Never allow overrides from tool args. |
| Tool-not-found response | 404 for both unauthorized and nonexistent tools | 403 for unauthorized, 404 for nonexistent | 404 everywhere: prevents tool enumeration by compromised agents |
| Output sanitization | Pattern-based stripping before returning to LLM | Trust tool output, harden system prompt | Pattern stripping as defense-in-depth; system prompt hardening is the primary backstop |
| Nonce store for replay protection | In-process Set (single instance) | Redis with TTL (multi-instance) | Redis as soon as you deploy more than one MCP server instance |
Production Considerations
Local MCP deployments carry a specific risk that Cloudflare named explicitly: a locally running MCP server with no request authentication can be reached by any process on the same host. If your MCP server runs in a development environment alongside arbitrary code, a supply chain attack on any dependency in that environment can reach your MCP server directly without going through any network-layer controls. Cloudflare’s guidance is direct: locally-deployed MCP is a “significant security liability” when it lacks per-request authentication. The mitigation is to treat even local MCP connections as untrusted and require signed requests.
For remote MCP deployments, TLS is not enough on its own. TLS encrypts the connection, but it does not authenticate the client. Add the HMAC request signing layer on top of TLS so that even a client with a valid certificate cannot replay old requests.
Dependency audits matter more for MCP servers than for typical API servers. The research finding that 66% of MCP servers contain code smells and 14.4% carry bug patterns is a supply chain signal, not just a code quality signal. Every MCP server dependency is a package that runs in the same process as your tool handlers. Audit your transitive dependency graph at least on every deployment, pin your lockfile, and set up automated alerts for known-vulnerable dependency versions.
Certificate rotation for mTLS deployments needs to be planned before you go live, not after. Implement a trust store that accepts both current and previous CA certificates during a rotation window. An MCP server that goes offline because a certificate expired is worse than one that temporarily accepts both old and new certificates.
The tool registration list should be the smallest set of tools that satisfies your use case. Every registered tool is an additional attack surface. A tool that is not registered cannot be exploited. Audit your tool list quarterly and remove tools that are no longer used by any active integration.
The Governance Layer Is Yours to Build
MCP will continue to expand. The protocol is becoming the standard wiring layer between AI agents and external systems, and the ecosystem of pre-built integrations is growing fast. None of that changes the core reality: the protocol is transport, not governance. Authentication, tenant isolation, input validation, rate limiting, and replay protection are all yours to build.
The good news is that none of these patterns are novel. OAuth 2.1, HMAC request signing, Zod schema validation, and Redis-backed rate limiting are standard infrastructure for any server-side application. The difference with MCP is that the consequences of skipping them are higher: a compromised MCP server does not just leak your data, it gives an AI agent ambient access to your tools, and whatever the agent can do, an attacker controlling the agent’s inputs can also do.
Build the governance layer before you open the tool registry. Every registered tool is a separate authorization boundary. Treat it like one.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.