Securing MCP servers
MCP servers, like any web application, need to be secured so they can be used by trusted users without abuse. The MCP specification uses OAuth 2.1 for authentication between MCP clients and servers.
This guide covers security best practices for MCP servers that act as OAuth proxies to third-party providers (like GitHub or Google).
Cloudflare's workers-oauth-provider ↗ handles token management, client registration, and access token validation:
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";import { MyMCP } from "./mcp";
export default new OAuthProvider({ authorizeEndpoint: "/authorize", tokenEndpoint: "/token", clientRegistrationEndpoint: "/register", apiHandlers: { "/mcp": MyMCP.serve("/mcp") }, defaultHandler: AuthHandler,});import { OAuthProvider } from "@cloudflare/workers-oauth-provider";import { MyMCP } from "./mcp";
export default new OAuthProvider({ authorizeEndpoint: "/authorize", tokenEndpoint: "/token", clientRegistrationEndpoint: "/register", apiHandlers: { "/mcp": MyMCP.serve("/mcp") }, defaultHandler: AuthHandler,});When your MCP server proxies to third-party OAuth providers, you must implement your own consent dialog before forwarding users upstream. This prevents the "confused deputy" problem where attackers could exploit cached consent.
Without CSRF protection, attackers can trick users into approving malicious OAuth clients. Use a random token stored in a secure cookie:
// Generate CSRF token when showing consent formfunction generateCSRFProtection() { const token = crypto.randomUUID(); const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`; return { token, setCookie };}
// Validate CSRF token on form submissionfunction validateCSRFToken(formData, request) { 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 Error("CSRF token mismatch"); }
// Clear cookie after use (one-time use) return { clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`, };}// Generate CSRF token when showing consent formfunction generateCSRFProtection() { const token = crypto.randomUUID(); const setCookie = `__Host-CSRF_TOKEN=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`; return { token, setCookie };}
// Validate CSRF token on form submissionfunction validateCSRFToken(formData: FormData, request: Request) { 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 Error("CSRF token mismatch"); }
// Clear cookie after use (one-time use) return { clearCookie: `__Host-CSRF_TOKEN=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`, };}Include the token as a hidden field in your consent form:
<input type="hidden" name="csrf_token" value="${csrfToken}" />User-controlled content (client names, logos, URIs) can execute malicious scripts if not sanitized:
function sanitizeText(text) { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'");}
function sanitizeUrl(url) { if (!url) return ""; try { const parsed = new URL(url); // Only allow http/https - reject javascript:, data:, file: if (!["http:", "https:"].includes(parsed.protocol)) { return ""; } return url; } catch { return ""; }}
// Always sanitize before renderingconst clientName = sanitizeText(client.clientName);const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));function sanitizeText(text: string): string { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'");}
function sanitizeUrl(url: string): string { if (!url) return ""; try { const parsed = new URL(url); // Only allow http/https - reject javascript:, data:, file: if (!["http:", "https:"].includes(parsed.protocol)) { return ""; } return url; } catch { return ""; }}
// Always sanitize before renderingconst clientName = sanitizeText(client.clientName);const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));CSP headers instruct browsers to block dangerous content:
function buildSecurityHeaders(setCookie, nonce) { const cspDirectives = [ "default-src 'none'", "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""), "style-src 'self' 'unsafe-inline'", "img-src 'self' https:", "font-src 'self'", "form-action 'self'", "frame-ancestors 'none'", // Prevent clickjacking "base-uri 'self'", "connect-src 'self'", ].join("; ");
return { "Content-Security-Policy": cspDirectives, "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Content-Type": "text/html; charset=utf-8", "Set-Cookie": setCookie, };}function buildSecurityHeaders(setCookie: string, nonce?: string): HeadersInit { const cspDirectives = [ "default-src 'none'", "script-src 'self'" + (nonce ? ` 'nonce-${nonce}'` : ""), "style-src 'self' 'unsafe-inline'", "img-src 'self' https:", "font-src 'self'", "form-action 'self'", "frame-ancestors 'none'", // Prevent clickjacking "base-uri 'self'", "connect-src 'self'", ].join("; ");
return { "Content-Security-Policy": cspDirectives, "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Content-Type": "text/html; charset=utf-8", "Set-Cookie": setCookie, };}Between the consent dialog and the OAuth callback, you need to ensure it is the same user. Use a state token stored in KV with a short expiration:
// Create state token before redirecting to upstream providerasync function createOAuthState(oauthReqInfo, kv) { const stateToken = crypto.randomUUID(); await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), { expirationTtl: 600, // 10 minutes }); return { stateToken };}
// Bind state to browser session with a hashed cookieasync function bindStateToSession(stateToken) { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest( "SHA-256", encoder.encode(stateToken), ); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join("");
return { setCookie: `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`, };}
// Validate state in callbackasync function validateOAuthState(request, kv) { const url = new URL(request.url); const stateFromQuery = url.searchParams.get("state");
if (!stateFromQuery) { throw new Error("Missing state parameter"); }
// Check state exists in KV const storedData = await kv.get(`oauth:state:${stateFromQuery}`); if (!storedData) { throw new Error("Invalid or expired state"); }
// Validate state matches session cookie // ... (hash comparison logic)
await kv.delete(`oauth:state:${stateFromQuery}`); return JSON.parse(storedData);}// Create state token before redirecting to upstream providerasync function createOAuthState(oauthReqInfo: AuthRequest, kv: KVNamespace) { const stateToken = crypto.randomUUID(); await kv.put(`oauth:state:${stateToken}`, JSON.stringify(oauthReqInfo), { expirationTtl: 600, // 10 minutes }); return { stateToken };}
// Bind state to browser session with a hashed cookieasync function bindStateToSession(stateToken: string) { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest( "SHA-256", encoder.encode(stateToken), ); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => b.toString(16).padStart(2, "0")) .join("");
return { setCookie: `__Host-CONSENTED_STATE=${hashHex}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`, };}
// Validate state in callbackasync function validateOAuthState(request: Request, kv: KVNamespace) { const url = new URL(request.url); const stateFromQuery = url.searchParams.get("state");
if (!stateFromQuery) { throw new Error("Missing state parameter"); }
// Check state exists in KV const storedData = await kv.get(`oauth:state:${stateFromQuery}`); if (!storedData) { throw new Error("Invalid or expired state"); }
// Validate state matches session cookie // ... (hash comparison logic)
await kv.delete(`oauth:state:${stateFromQuery}`); return JSON.parse(storedData);}The __Host- prefix prevents subdomain attacks, which is especially important on *.workers.dev domains:
- Must be set with
Secureflag (HTTPS only) - Must have
Path=/ - Must not have a
Domainattribute
Without __Host-, an attacker controlling evil.workers.dev could set cookies for your mcp-server.workers.dev domain.
If running multiple OAuth flows on the same domain, namespace your cookies:
__Host-CSRF_TOKEN_GITHUB__Host-CSRF_TOKEN_GOOGLE__Host-APPROVED_CLIENTS_GITHUB__Host-APPROVED_CLIENTS_GOOGLEMaintain a registry of approved client IDs per user to avoid showing the consent dialog repeatedly:
async function addApprovedClient(request, clientId, cookieSecret) { const existingClients = (await getApprovedClientsFromCookie(request, cookieSecret)) || []; const updatedClients = [...new Set([...existingClients, clientId])];
const payload = JSON.stringify(updatedClients); 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`;}async function addApprovedClient( request: Request, clientId: string, cookieSecret: string,) { const existingClients = (await getApprovedClientsFromCookie(request, cookieSecret)) || []; const updatedClients = [...new Set([...existingClients, clientId])];
const payload = JSON.stringify(updatedClients); 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, verify the HMAC signature before trusting the data. If the client is not in the approved list, show the consent dialog.
| Protection | Purpose |
|---|---|
| CSRF tokens | Prevent forged consent approvals |
| Input sanitization | Prevent XSS in consent dialogs |
| CSP headers | Block injected scripts |
| State binding | Prevent session fixation |
__Host- cookies | Prevent subdomain attacks |
| HMAC signatures | Verify cookie integrity |
Was this helpful?
- Resources
- API
- New to Cloudflare?
- Directory
- Sponsorships
- Open Source
- Support
- Help Center
- System Status
- Compliance
- GDPR
- Company
- cloudflare.com
- Our team
- Careers
- © 2026 Cloudflare, Inc.
- Privacy Policy
- Terms of Use
- Report Security Issues
- Trademark
-