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.
Prerequisites
- 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
Add OAuth 2.1 with PKCE authentication to MCP transport
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.
1// src/auth.ts2import * as jose from "jose";34interface AuthConfig {5 issuer: string;6 audience: string;7 jwksUrl: string;8}910let jwks: jose.JSONWebKeySet | null = null;1112export async function validateToken(13 token: string,14 config: AuthConfig15): Promise<{ sub: string; scopes: string[] }> {16 if (!jwks) {17 const response = await fetch(config.jwksUrl);18 jwks = await response.json();19 }2021 const JWKS = jose.createLocalJWKSet(jwks!);2223 const { payload } = await jose.jwtVerify(token, JWKS, {24 issuer: config.issuer,25 audience: config.audience,26 });2728 return {29 sub: payload.sub || "unknown",30 scopes: (payload.scope as string || "").split(" "),31 };32}3334export function requireScope(userScopes: string[], required: string): boolean {35 return userScopes.includes(required) || userScopes.includes("admin");36}Expected result: A validateToken function that verifies JWTs against your OAuth provider's JWKS endpoint.
Validate Origin headers to prevent cross-origin tool poisoning
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.
1// src/origin-validation.ts2const ALLOWED_ORIGINS = new Set([3 "https://claude.ai",4 "vscode://cursor",5 "https://your-app.example.com",6]);78export function validateOrigin(origin: string | undefined): boolean {9 if (!origin) return false; // Reject missing Origin10 return ALLOWED_ORIGINS.has(origin);11}1213// Apply to HTTP/SSE transport middleware14export function originMiddleware(15 req: { headers: Record<string, string | undefined> },16 res: { status: (code: number) => any; json: (body: any) => void },17 next: () => void18): void {19 const origin = req.headers["origin"];20 if (!validateOrigin(origin)) {21 console.error(`[security] Rejected connection from origin: ${origin}`);22 res.status(403).json({ error: "Origin not allowed" });23 return;24 }25 next();26}Expected result: Origin validation middleware that rejects connections from unauthorized clients.
Sanitize all tool inputs to prevent injection attacks
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.
1// src/sanitize.ts2import path from "path";34export function sanitizePath(basePath: string, userPath: string): string {5 // Resolve to absolute, then verify it's within basePath6 const resolved = path.resolve(basePath, userPath);7 if (!resolved.startsWith(path.resolve(basePath))) {8 throw new Error("Path traversal detected");9 }10 // Block hidden files and sensitive patterns11 const parts = userPath.split(path.sep);12 for (const part of parts) {13 if (part.startsWith(".") && part !== "." && part !== "..") {14 throw new Error("Access to hidden files not allowed");15 }16 }17 return resolved;18}1920export function sanitizeString(input: string, maxLength: number = 10000): string {21 if (input.length > maxLength) {22 throw new Error(`Input exceeds maximum length of ${maxLength}`);23 }24 // Strip null bytes25 return input.replace(/\0/g, "");26}2728export function sanitizeRegex(pattern: string): RegExp {29 // Prevent ReDoS by limiting pattern complexity30 if (pattern.length > 200) throw new Error("Pattern too long");31 if (/\(.*\+.*\)\+|\(.*\*.*\)\*/.test(pattern)) {32 throw new Error("Potentially catastrophic regex pattern");33 }34 return new RegExp(pattern, "i");35}Expected result: Sanitization functions that validate paths, strings, and regex patterns before tool execution.
Implement scope-based authorization per tool
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.
1// src/authorized-tool.ts2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";3import { z } from "zod";4import { requireScope } from "./auth.js";56interface ToolAuth {7 requiredScope: string;8 userScopes: string[]; // Set per-connection from JWT9}1011export function registerAuthorizedTool(12 server: McpServer,13 name: string,14 description: string,15 schema: Record<string, z.ZodTypeAny>,16 auth: ToolAuth,17 handler: (params: any) => Promise<any>18) {19 server.tool(name, description, schema, async (params) => {20 if (!requireScope(auth.userScopes, auth.requiredScope)) {21 console.error(`[auth] Denied ${name} - missing scope: ${auth.requiredScope}`);22 return {23 content: [{ type: "text", text: `Error: Insufficient permissions. Required scope: ${auth.requiredScope}` }],24 isError: true,25 };26 }2728 console.error(`[auth] Allowed ${name} for user with scopes: ${auth.userScopes.join(", ")}`);29 return handler(params);30 });31}Expected result: A tool registration wrapper that checks JWT scopes before executing tool handlers.
Add audit logging for all tool invocations
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.
1// src/audit.ts2export interface AuditEntry {3 timestamp: string;4 user: string;5 tool: string;6 params: Record<string, unknown>;7 status: "success" | "error" | "denied";8 duration_ms: number;9}1011export function logAudit(entry: AuditEntry): void {12 console.error(JSON.stringify({13 level: "audit",14 ...entry,15 }));16}1718// Wrap tool handlers with audit logging19export function withAudit(20 toolName: string,21 userId: string,22 handler: (params: any) => Promise<any>23): (params: any) => Promise<any> {24 return async (params: any) => {25 const start = Date.now();26 try {27 const result = await handler(params);28 logAudit({29 timestamp: new Date().toISOString(),30 user: userId,31 tool: toolName,32 params,33 status: result.isError ? "error" : "success",34 duration_ms: Date.now() - start,35 });36 return result;37 } catch (error) {38 logAudit({39 timestamp: new Date().toISOString(),40 user: userId,41 tool: toolName,42 params,43 status: "error",44 duration_ms: Date.now() - start,45 });46 throw error;47 }48 };49}Expected result: Structured JSON audit logs emitted to stderr for every tool invocation with timing and status.
Configure TLS and deploy securely
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.
1# Caddy reverse proxy configuration for MCP SSE server2# Caddyfile3mcp.example.com {4 reverse_proxy localhost:30015 6 header {7 Strict-Transport-Security "max-age=31536000; includeSubDomains"8 X-Content-Type-Options "nosniff"9 X-Frame-Options "DENY"10 Referrer-Policy "strict-origin-when-cross-origin"11 }12}1314# Or for Docker deployment:15# docker run --read-only --user 1001:1001 \16# -e OAUTH_ISSUER=https://auth.example.com \17# -e OAUTH_AUDIENCE=mcp-server \18# mcp-server:latestExpected result: MCP server running behind TLS with security headers and minimal filesystem permissions.
Complete working example
1import path from "path";2import * as jose from "jose";34// --- Path Sanitization ---5export function sanitizePath(basePath: string, userPath: string): string {6 const resolved = path.resolve(basePath, userPath);7 if (!resolved.startsWith(path.resolve(basePath))) {8 throw new Error("Path traversal detected");9 }10 return resolved;11}1213// --- JWT Validation ---14let cachedJwks: { keys: jose.JSONWebKeySet; fetchedAt: number } | null = null;1516export async function validateJwt(17 token: string,18 issuer: string,19 audience: string,20 jwksUrl: string21): Promise<{ sub: string; scopes: string[] }> {22 const now = Date.now();23 if (!cachedJwks || now - cachedJwks.fetchedAt > 300_000) {24 const res = await fetch(jwksUrl);25 cachedJwks = { keys: await res.json(), fetchedAt: now };26 }27 const JWKS = jose.createLocalJWKSet(cachedJwks.keys);28 const { payload } = await jose.jwtVerify(token, JWKS, { issuer, audience });29 return {30 sub: payload.sub || "unknown",31 scopes: (payload.scope as string || "").split(" "),32 };33}3435// --- Scope Check ---36export function hasScope(scopes: string[], required: string): boolean {37 return scopes.includes(required) || scopes.includes("admin");38}3940// --- Input Sanitization ---41export function sanitizeString(input: string, max = 10000): string {42 if (input.length > max) throw new Error(`Input too long: ${input.length}/${max}`);43 return input.replace(/\0/g, "");44}4546// --- Audit Logging ---47export function audit(event: {48 user: string; tool: string; status: string; ms: number;49}): void {50 console.error(JSON.stringify({51 level: "audit",52 ts: new Date().toISOString(),53 ...event,54 }));55}5657// --- Origin Validation ---58const ALLOWED_ORIGINS = new Set([59 "https://claude.ai",60 "vscode://cursor",61]);6263export function isAllowedOrigin(origin?: string): boolean {64 if (!origin) return false;65 return ALLOWED_ORIGINS.has(origin);66}Common mistakes when securing an MCP server in production
Why it's a problem: Running MCP servers without any authentication, allowing any client to call tools
How to avoid: Implement OAuth 2.1 for HTTP transport or restrict stdio servers to specific user accounts with OS-level permissions.
Why it's a problem: Not validating file paths, allowing ../../../etc/passwd traversal attacks
How to avoid: Always resolve paths to absolute and verify they start with the allowed base directory before any file operation.
Why it's a problem: Logging sensitive data (passwords, API keys) in audit logs
How to avoid: Redact known sensitive fields from audit log entries. Use an allowlist of safe-to-log parameters instead of logging everything.
Why it's a problem: Ignoring the CVE-2025-6514 tool poisoning risk by not validating tool descriptions
How to avoid: 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
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm deploying an MCP server to production. Show me how to add OAuth 2.1 JWT validation, Origin header checking, path traversal prevention, scope-based tool authorization, and structured audit logging. Use TypeScript with the jose library.
Secure my MCP server with OAuth 2.1 PKCE, Origin validation, input sanitization, scope-based authorization per tool, and JSON audit logging to stderr. Reference CVE-2025-6514 mitigations.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation