# How to secure an MCP server in production

- Tool: MCP
- Difficulty: Advanced
- Time required: 35-50 min
- Compatibility: MCP TypeScript SDK v1.x, Node.js 18+, OAuth 2.1 provider
- Last updated: March 2026

## TL;DR

Secure your MCP server for production with OAuth 2.1 + PKCE authentication, Origin header validation, input sanitization, and rate limiting. The MCP spec added an authorization framework after CVE-2025-6514 exposed servers to tool poisoning attacks. This tutorial covers the full security stack: transport encryption, token validation, path traversal prevention, and audit logging.

## Securing MCP Servers for Production Deployment

MCP servers expose powerful capabilities to AI assistants — file access, database queries, API calls. Without proper security, these tools become attack vectors. The March 2025 CVE-2025-6514 disclosure showed how malicious tool descriptions could poison AI behavior, and the subsequent MCP spec update added OAuth 2.1 as the standard authentication mechanism. This tutorial covers the complete security stack: authentication, authorization, input validation, rate limiting, and audit logging.

## Before you start

- A working MCP server ready for production hardening
- Understanding of OAuth 2.0/2.1 concepts (tokens, scopes, PKCE)
- Node.js 18+ with npm installed
- Basic knowledge of HTTP security headers and TLS

## Step-by-step guide

### 1. Add OAuth 2.1 with PKCE authentication to MCP transport

The MCP specification recommends OAuth 2.1 with PKCE for authenticating clients to servers over HTTP/SSE transport. When using stdio transport (local servers), authentication happens at the OS level. For remote HTTP-based MCP servers, implement the OAuth flow: the client obtains a token from your OAuth provider, includes it in requests, and the server validates it. Use the jose library for JWT validation without full OAuth server dependencies.

```
// src/auth.ts
import * as jose from "jose";

interface AuthConfig {
  issuer: string;
  audience: string;
  jwksUrl: string;
}

let jwks: jose.JSONWebKeySet | null = null;

export async function validateToken(
  token: string,
  config: AuthConfig
): Promise<{ sub: string; scopes: string[] }> {
  if (!jwks) {
    const response = await fetch(config.jwksUrl);
    jwks = await response.json();
  }

  const JWKS = jose.createLocalJWKSet(jwks!);

  const { payload } = await jose.jwtVerify(token, JWKS, {
    issuer: config.issuer,
    audience: config.audience,
  });

  return {
    sub: payload.sub || "unknown",
    scopes: (payload.scope as string || "").split(" "),
  };
}

export function requireScope(userScopes: string[], required: string): boolean {
  return userScopes.includes(required) || userScopes.includes("admin");
}
```

**Expected result:** A validateToken function that verifies JWTs against your OAuth provider's JWKS endpoint.

### 2. Validate Origin headers to prevent cross-origin tool poisoning

CVE-2025-6514 demonstrated that malicious MCP tool descriptions could trick AI clients into calling other servers' tools with crafted inputs. Defend against this by validating the Origin header on incoming connections. Only allow connections from known clients (Claude Desktop, Cursor, your own applications). Reject requests with unknown or missing Origin headers when running over HTTP transport.

```
// src/origin-validation.ts
const ALLOWED_ORIGINS = new Set([
  "https://claude.ai",
  "vscode://cursor",
  "https://your-app.example.com",
]);

export function validateOrigin(origin: string | undefined): boolean {
  if (!origin) return false; // Reject missing Origin
  return ALLOWED_ORIGINS.has(origin);
}

// Apply to HTTP/SSE transport middleware
export function originMiddleware(
  req: { headers: Record<string, string | undefined> },
  res: { status: (code: number) => any; json: (body: any) => void },
  next: () => void
): void {
  const origin = req.headers["origin"];
  if (!validateOrigin(origin)) {
    console.error(`[security] Rejected connection from origin: ${origin}`);
    res.status(403).json({ error: "Origin not allowed" });
    return;
  }
  next();
}
```

**Expected result:** Origin validation middleware that rejects connections from unauthorized clients.

### 3. Sanitize all tool inputs to prevent injection attacks

Every tool input must be sanitized before use. Path parameters must be checked for directory traversal (../ sequences). SQL parameters must use parameterized queries. Shell command arguments must be escaped. Create a validation library that each tool handler calls before processing inputs. Defense in depth means validating at the schema level (Zod) AND at the handler level.

```
// src/sanitize.ts
import path from "path";

export function sanitizePath(basePath: string, userPath: string): string {
  // Resolve to absolute, then verify it's within basePath
  const resolved = path.resolve(basePath, userPath);
  if (!resolved.startsWith(path.resolve(basePath))) {
    throw new Error("Path traversal detected");
  }
  // Block hidden files and sensitive patterns
  const parts = userPath.split(path.sep);
  for (const part of parts) {
    if (part.startsWith(".") && part !== "." && part !== "..") {
      throw new Error("Access to hidden files not allowed");
    }
  }
  return resolved;
}

export function sanitizeString(input: string, maxLength: number = 10000): string {
  if (input.length > maxLength) {
    throw new Error(`Input exceeds maximum length of ${maxLength}`);
  }
  // Strip null bytes
  return input.replace(/\0/g, "");
}

export function sanitizeRegex(pattern: string): RegExp {
  // Prevent ReDoS by limiting pattern complexity
  if (pattern.length > 200) throw new Error("Pattern too long");
  if (/\(.*\+.*\)\+|\(.*\*.*\)\*/.test(pattern)) {
    throw new Error("Potentially catastrophic regex pattern");
  }
  return new RegExp(pattern, "i");
}
```

**Expected result:** Sanitization functions that validate paths, strings, and regex patterns before tool execution.

### 4. Implement scope-based authorization per tool

Not every authenticated user should access every tool. Assign scopes to tools and check the user's JWT scopes before executing. Read-only tools require a read scope, write tools require write, and admin tools require admin. This follows the principle of least privilege — users only get access to the tools their role requires.

```
// src/authorized-tool.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { requireScope } from "./auth.js";

interface ToolAuth {
  requiredScope: string;
  userScopes: string[];  // Set per-connection from JWT
}

export function registerAuthorizedTool(
  server: McpServer,
  name: string,
  description: string,
  schema: Record<string, z.ZodTypeAny>,
  auth: ToolAuth,
  handler: (params: any) => Promise<any>
) {
  server.tool(name, description, schema, async (params) => {
    if (!requireScope(auth.userScopes, auth.requiredScope)) {
      console.error(`[auth] Denied ${name} - missing scope: ${auth.requiredScope}`);
      return {
        content: [{ type: "text", text: `Error: Insufficient permissions. Required scope: ${auth.requiredScope}` }],
        isError: true,
      };
    }

    console.error(`[auth] Allowed ${name} for user with scopes: ${auth.userScopes.join(", ")}`);
    return handler(params);
  });
}
```

**Expected result:** A tool registration wrapper that checks JWT scopes before executing tool handlers.

### 5. Add audit logging for all tool invocations

Log every tool call with the user identity, tool name, parameters, result status, and timestamp. This audit trail is essential for security incident investigation and compliance. Write logs to stderr in structured JSON format so they can be ingested by log aggregation services like Datadog or Elastic. For production servers processing sensitive data, RapidDev recommends storing audit logs in an append-only database for tamper resistance.

```
// src/audit.ts
export interface AuditEntry {
  timestamp: string;
  user: string;
  tool: string;
  params: Record<string, unknown>;
  status: "success" | "error" | "denied";
  duration_ms: number;
}

export function logAudit(entry: AuditEntry): void {
  console.error(JSON.stringify({
    level: "audit",
    ...entry,
  }));
}

// Wrap tool handlers with audit logging
export function withAudit(
  toolName: string,
  userId: string,
  handler: (params: any) => Promise<any>
): (params: any) => Promise<any> {
  return async (params: any) => {
    const start = Date.now();
    try {
      const result = await handler(params);
      logAudit({
        timestamp: new Date().toISOString(),
        user: userId,
        tool: toolName,
        params,
        status: result.isError ? "error" : "success",
        duration_ms: Date.now() - start,
      });
      return result;
    } catch (error) {
      logAudit({
        timestamp: new Date().toISOString(),
        user: userId,
        tool: toolName,
        params,
        status: "error",
        duration_ms: Date.now() - start,
      });
      throw error;
    }
  };
}
```

**Expected result:** Structured JSON audit logs emitted to stderr for every tool invocation with timing and status.

### 6. Configure TLS and deploy securely

For HTTP/SSE transport, always use TLS in production. Place your MCP server behind a reverse proxy (nginx, Caddy) that handles TLS termination. Set security headers: Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options. For stdio transport, ensure the server binary has minimal file system permissions and runs as a non-root user.

```
# Caddy reverse proxy configuration for MCP SSE server
# Caddyfile
mcp.example.com {
  reverse_proxy localhost:3001
  
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}

# Or for Docker deployment:
# docker run --read-only --user 1001:1001 \
#   -e OAUTH_ISSUER=https://auth.example.com \
#   -e OAUTH_AUDIENCE=mcp-server \
#   mcp-server:latest
```

**Expected result:** MCP server running behind TLS with security headers and minimal filesystem permissions.

## Complete code example

File: `src/security.ts`

```typescript
import path from "path";
import * as jose from "jose";

// --- Path Sanitization ---
export function sanitizePath(basePath: string, userPath: string): string {
  const resolved = path.resolve(basePath, userPath);
  if (!resolved.startsWith(path.resolve(basePath))) {
    throw new Error("Path traversal detected");
  }
  return resolved;
}

// --- JWT Validation ---
let cachedJwks: { keys: jose.JSONWebKeySet; fetchedAt: number } | null = null;

export async function validateJwt(
  token: string,
  issuer: string,
  audience: string,
  jwksUrl: string
): Promise<{ sub: string; scopes: string[] }> {
  const now = Date.now();
  if (!cachedJwks || now - cachedJwks.fetchedAt > 300_000) {
    const res = await fetch(jwksUrl);
    cachedJwks = { keys: await res.json(), fetchedAt: now };
  }
  const JWKS = jose.createLocalJWKSet(cachedJwks.keys);
  const { payload } = await jose.jwtVerify(token, JWKS, { issuer, audience });
  return {
    sub: payload.sub || "unknown",
    scopes: (payload.scope as string || "").split(" "),
  };
}

// --- Scope Check ---
export function hasScope(scopes: string[], required: string): boolean {
  return scopes.includes(required) || scopes.includes("admin");
}

// --- Input Sanitization ---
export function sanitizeString(input: string, max = 10000): string {
  if (input.length > max) throw new Error(`Input too long: ${input.length}/${max}`);
  return input.replace(/\0/g, "");
}

// --- Audit Logging ---
export function audit(event: {
  user: string; tool: string; status: string; ms: number;
}): void {
  console.error(JSON.stringify({
    level: "audit",
    ts: new Date().toISOString(),
    ...event,
  }));
}

// --- Origin Validation ---
const ALLOWED_ORIGINS = new Set([
  "https://claude.ai",
  "vscode://cursor",
]);

export function isAllowedOrigin(origin?: string): boolean {
  if (!origin) return false;
  return ALLOWED_ORIGINS.has(origin);
}
```

## Common mistakes

- **Running MCP servers without any authentication, allowing any client to call tools** — undefined Fix: Implement OAuth 2.1 for HTTP transport or restrict stdio servers to specific user accounts with OS-level permissions.
- **Not validating file paths, allowing ../../../etc/passwd traversal attacks** — undefined Fix: Always resolve paths to absolute and verify they start with the allowed base directory before any file operation.
- **Logging sensitive data (passwords, API keys) in audit logs** — undefined Fix: Redact known sensitive fields from audit log entries. Use an allowlist of safe-to-log parameters instead of logging everything.
- **Ignoring the CVE-2025-6514 tool poisoning risk by not validating tool descriptions** — undefined Fix: Validate Origin headers, use allowlisted MCP servers only, and never auto-approve tool calls from unknown servers.

## Best practices

- Use OAuth 2.1 with PKCE for remote MCP servers — never roll your own auth
- Validate Origin headers to prevent cross-origin tool abuse
- Sanitize every tool input: paths, strings, regex patterns, SQL parameters
- Implement scope-based authorization so users only access the tools their role requires
- Log every tool invocation with user identity, parameters, and result status
- Run servers with minimal filesystem permissions and as non-root users
- Use TLS for all HTTP/SSE MCP transport in production
- Cache JWKS responses to avoid latency on every authentication check

## Frequently asked questions

### Is authentication required for local MCP servers running via stdio?

Stdio transport is inherently authenticated by the OS — only the user running the client process can connect. You still need input sanitization and path validation, but OAuth is not needed for local stdio servers.

### What is CVE-2025-6514 and how does it affect MCP?

CVE-2025-6514 was a tool poisoning vulnerability where malicious MCP tool descriptions could inject instructions into AI prompts, tricking the AI into performing unauthorized actions. Mitigations include Origin validation, tool description sanitization, and user confirmation before executing tool calls.

### Should I use OAuth 2.0 or OAuth 2.1 for MCP?

Use OAuth 2.1, which requires PKCE for all clients and deprecates the implicit grant. The MCP specification recommends OAuth 2.1 as the standard authentication mechanism for remote servers.

### How do I handle authentication for MCP servers behind a reverse proxy?

Let the reverse proxy terminate TLS and forward the Authorization header to the MCP server. The server validates the JWT locally using the JWKS endpoint. This keeps the MCP server simple and lets the proxy handle TLS complexity.

### Can RapidDev help with MCP server security audits?

Yes, RapidDev offers security reviews for MCP server deployments covering authentication, authorization, input validation, and compliance requirements. They can identify vulnerabilities before production deployment.

### How do I prevent denial-of-service attacks on my MCP server?

Combine rate limiting (per-user and per-tool), input size limits, request timeouts, and connection limits. Deploy behind a CDN or WAF for additional DDoS protection on HTTP transport.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-secure-mcp-server-in-production
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-secure-mcp-server-in-production
