# Securing MCP Servers Model Context Protocol servers, like every other web application, need to be secured so they can be used by trusted users without abuse. The MCP spec uses the OAuth 2.1 standard for authentication between MCP clients and servers. Cloudflare's `workers-oauth-provider` lets you secure your MCP Server (or any application) running on a Cloudflare Worker. The provider handles token management, client registration, and access token validation automatically. ```typescript import { OAuthProvider, OAuthError } from "@cloudflare/workers-oauth-provider"; import { createMcpHandler } from "agents/mcp"; // A Worker that exposes an MCP server const apiHandler = { async fetch(request: Request, env: unknown, ctx: ExecutionContext) { return createMcpHandler(server)(request, env, ctx); } }; // Wrap with OAuth protection export default new OAuthProvider({ authorizeEndpoint: "/authorize", tokenEndpoint: "/oauth/token", clientRegistrationEndpoint: "/oauth/register", apiRoute: "/mcp", // Protected MCP endpoint apiHandler: apiHandler, // Your MCP server defaultHandler: AuthHandler // Handles consent flow }); ``` However, most MCP servers aren't just servers, they can actually be OAuth clients too. Your MCP server might sit between Claude Desktop and a third-party API like GitHub or Google. To Claude, you're a server. To GitHub, you're a client. This allows your users to authenticate and use their GitHub credentials to access your MCP server. We call this a proxy server. There are a few security footguns to securely building a proxy server. The rest of this document aims to outline best practises to securing an MCP server. ## `redirect_uri` validation The `workers-oauth-provider` package handles this automatically. It validates that the `redirect_uri` in the authorization request matches one of the registered redirect URIs for the client. This prevents attackers from redirecting authorization codes to their own endpoints. ## Consent dialog When your MCP server acts as an OAuth proxy to third-party providers (like Google, GitHub, etc.), you must implement your own consent dialog before forwarding users to the upstream authorization server. This prevents the ["confused deputy"](https://en.wikipedia.org/wiki/Confused_deputy_problem) problem where attackers could exploit cached consent from the third-party provider to gain unauthorized access. Your consent dialog should clearly identify the requesting MCP client by name and display the specific scopes being requested. Implementing this consent flow requires thinking about a few security concerns. ### CSRF Protection Without CSRF protection, an attacker can trick users into approving malicious OAuth clients. Use a random token stored in a secure cookie and validate it on form submission. ```typescript // GET /authorize - Generate CSRF token when showing consent form app.get("/authorize", async (c) => { const { token: csrfToken, setCookie } = generateCSRFProtection(); return renderConsentDialog(c.req.raw, { client: await c.env.OAUTH_PROVIDER.lookupClient(clientId), csrfToken, // Pass to form as hidden field setCookie // Set the cookie // ... other dialog data }); }); // POST /authorize - Validate CSRF token when user approves app.post("/authorize", async (c) => { const formData = await c.req.raw.formData(); // Validate CSRF token exists and matches cookie const { clearCookie } = validateCSRFToken(formData, c.req.raw); // Then redirect to upstream provider and clear the CSRF with the clearCookie header }); // Helper functions function generateCSRFProtection(): CSRFProtectionResult { const token = crypto.randomUUID(); const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`; return { token, setCookie }; } function validateCSRFToken( formData: FormData, request: Request ): ValidateCSRFResult { const tokenFromForm = formData.get("csrf_token"); const cookieHeader = request.headers.get("Cookie") || ""; const tokenFromCookie = cookieHeader .split(";") .find((c) => c.trim().startsWith("__Host-CSRF_TOKEN=")) ?.split("=")[1]; if (!tokenFromForm || !tokenFromCookie || tokenFromForm !== tokenFromCookie) { throw new OAuthError("invalid_request", "CSRF token mismatch", 400); } // Clear cookie after use (one-time use per RFC 9700) return { clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0` }; } ``` Include the token as a hidden field in your consent form: ```html ``` ### Input sanitization User-controlled content (client names, logos, URIs) in your consent dialog can execute malicious scripts if not sanitized. Client registration is dynamic, so you must treat all client metadata as untrusted input. **Required protections:** - **Client names/descriptions**: HTML-escape all text before rendering (escape `<`, `>`, `&`, `"`, `'`) - **Logo URLs**: Validate URL scheme (allow only `http:` and `https:`), reject `javascript:`, `data:`, `file:` schemes - **Client URIs**: Same as logo URLs - whitelist http/https only - **Scopes**: Treat as text, HTML-escape before display ```typescript function sanitizeText(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function sanitizeUrl(url: string): string { if (!url) return ""; try { const parsed = new URL(url); if (!["http:", "https:"].includes(parsed.protocol)) { return ""; // Reject dangerous schemes } return url; } catch { return ""; // Invalid URL } } // Always sanitize before rendering const clientName = sanitizeText(client.clientName); const logoUrl = sanitizeText(sanitizeUrl(client.logoUri)); ``` ### Content Security Policy (CSP) CSP headers instruct browsers to block dangerous content and behaviors. They provide defence in depth from multiple attack vectors. ```typescript function buildSecurityHeaders(setCookie: string, nonce?: string): HeadersInit { const cspDirectives = [ "default-src 'none'", // Deny everything by default "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""), // Allow scripts from same origin (+ nonce if using inline JS) "style-src 'self' 'unsafe-inline'", // Allow inline styles for rendering "img-src 'self' https:", // Allow client logos from HTTPS URLs "font-src 'self'", // Allow web fonts from same origin "form-action 'self'", // Only allow form submissions to same origin "frame-ancestors 'none'", // Prevent clickjacking - block ALL iframe embedding "base-uri 'self'", // Prevent base tag injection attacks "connect-src 'self'" // Restrict fetch/XHR to same origin ].join("; "); return { "Content-Security-Policy": cspDirectives, "X-Frame-Options": "DENY", // Legacy clickjacking protection for older browsers "X-Content-Type-Options": "nosniff", // Prevent MIME sniffing attacks "Content-Type": "text/html; charset=utf-8", "Set-Cookie": setCookie }; } ``` ### Inline JavaScript If your consent dialog needs inline JavaScript, use data attributes and nonces to prevent XSS attacks. ```typescript // Generate a unique nonce per request const nonce = crypto.randomUUID(); const htmlContent = `

Authorization approved! Redirecting...

`; return new Response(htmlContent, { headers: buildSecurityHeaders(setCookie, nonce) // Pass nonce to include in CSP }); ``` - **Data attributes** store user-controlled data (like URLs) separately from JavaScript code, ensuring they're always treated as strings, never as executable code - **Nonces** combined with the correct CSP headers (shown above) allow your specific inline script to execute while blocking any injected scripts Note: Frameworks such as Vite will automatically handle nonce generation and insertion for you. See their docs on [Content Security Policy](https://vite.dev/guide/features.html#content-security-policy-csp) for more information. ## Handling State Between the consent dialog and the callback there is a gap where the user could do something nasty. We need to make sure it is the same user that hits authorize and then reaches back to our callback. Use a random state token stored server-side in KV with a short expiration time. ```typescript // Use in POST /authorize - after CSRF validation, before redirecting to upstream provider // Firstly create a state token in KV async function createOAuthState( oauthReqInfo: AuthRequest, kv: KVNamespace ): Promise<{ stateToken: string }> { const stateToken = crypto.randomUUID(); await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), { expirationTtl: 600 // 10 minutes }); return { stateToken }; } // Bind state to browser session async function bindStateToSession( stateToken: string ): Promise<{ setCookie: string }> { const consentedStateCookieName = "__Host-CONSENTED_STATE"; // Hash the state token to create a derived parameter const encoder = new TextEncoder(); const data = encoder.encode(stateToken); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray .map((b) => b.toString(16).padStart(2, "0")) .join(""); const setCookie = `${consentedStateCookieName}=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`; return { setCookie }; } // In the GET /callback - validate state from query params against both the KV and the session cookie before exchanging code async function validateOAuthState( request: Request, kv: KVNamespace ): Promise<{ oauthReqInfo: AuthRequest; clearCookie: string }> { const consentedStateCookieName = "__Host-CONSENTED_STATE"; const url = new URL(request.url); const stateFromQuery = url.searchParams.get("state"); if (!stateFromQuery) { throw new OAuthError("invalid_request", "Missing state parameter", 400); } // Check 1: Validate state exists in KV const storedDataJson = await kv.get(`oauth:state:${stateFromQuery}`); if (!storedDataJson) { throw new OAuthError("invalid_request", "Invalid or expired state", 400); } // Check 2: Validate state matches session cookie const cookieHeader = request.headers.get("Cookie") || ""; const cookies = cookieHeader.split(";").map((c) => c.trim()); const consentedStateCookie = cookies.find((c) => c.startsWith(`${consentedStateCookieName}=`) ); const consentedStateHash = consentedStateCookie ? consentedStateCookie.substring(consentedStateCookieName.length + 1) : null; if (!consentedStateHash) { throw new OAuthError( "invalid_request", "Missing session binding cookie - authorization flow must be restarted", 400 ); } // Hash the state from query and compare with cookie const encoder = new TextEncoder(); const data = encoder.encode(stateFromQuery); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); const stateHash = hashArray .map((b) => b.toString(16).padStart(2, "0")) .join(""); if (stateHash !== consentedStateHash) { throw new OAuthError( "invalid_request", "State token does not match session - possible CSRF attack detected", 400 ); } // Both checks passed - clean up KV and return the clear cookie header await kv.delete(`oauth:state:${stateFromQuery}`); const clearCookie = `${consentedStateCookieName}=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`; return { oauthReqInfo: JSON.parse(storedDataJson), clearCookie }; } ``` ## Approved client MCP proxy servers must maintain a registry of approved client IDs per user and check this registry before initiating the third-party authorization flow. Store approved clients in a secure, cryptographically signed cookie with HMAC-SHA256. ```typescript // Use in POST /authorize - after user approves, add client to approved list export async function addApprovedClient( request: Request, clientId: string, cookieSecret: string ): Promise { const existingApprovedClients = (await getApprovedClientsFromCookie(request, cookieSecret)) || []; const updatedApprovedClients = Array.from( new Set([...existingApprovedClients, clientId]) ); const payload = JSON.stringify(updatedApprovedClients); const signature = await signData(payload, cookieSecret); // HMAC-SHA256 const cookieValue = `${signature}.${btoa(payload)}`; return `__Host-APPROVED_CLIENTS=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=2592000`; } ``` When reading the cookie in GET /authorize (before showing the consent dialog), verify the signature before trusting the data. If the signature doesn't match or the client isn't in the list, show the consent dialog. If the client is approved, skip the dialog and proceed directly to creating the OAuth state. ## Cookies ### Why `__Host-` prefix? Throughout this document you'll see cookies named with the `__Host-` prefix (like `__Host-CSRF_TOKEN` and `__Host-APPROVED_CLIENTS`). This is especially important for MCP servers running on `*.workers.dev` domains. The `__Host-` prefix is a security feature that prevents subdomain attacks. When you set a cookie with this prefix: - It **must** be set with the `Secure` flag (HTTPS only) - It **must** have `Path=/` - It **must not** have a `Domain` attribute This means the cookie is locked to the exact domain that set it. Without `__Host-`, an attacker controlling `evil.workers.dev` could set cookies for your `mcp-server.workers.dev` domain and potentially inject malicious CSRF tokens or approved client lists. The `__Host-` prefix prevents this by ensuring only your specific domain can set and read these cookies. ### Multiple OAuth clients on the same host If you're running multiple OAuth flows on the same domain (e.g., GitHub OAuth and Google OAuth on the same worker), namespace your cookies to prevent collisions. Instead of `__Host-CSRF_TOKEN`, use `__Host-CSRF_TOKEN_GITHUB` and `__Host-CSRF_TOKEN_GOOGLE`. Same applies for approved clients: `__Host-APPROVED_CLIENTS_GITHUB` vs `__Host-APPROVED_CLIENTS_GOOGLE`. This ensures each OAuth flow maintains isolated state. # More info - [MCP Authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) - [MCP Security Best Practices](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices) - [RFC 9700 - Protecting Redirect Based Flows](https://www.rfc-editor.org/rfc/rfc9700#name-protecting-redirect-based-f) - [RFC 9700 - Best Practices](https://www.rfc-editor.org/rfc/rfc9700#name-best-practices)