Skip to content
Cloudflare Docs

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).

OAuth protection with workers-oauth-provider

Cloudflare's workers-oauth-provider handles token management, client registration, and access token validation:

JavaScript
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.

CSRF protection

Without CSRF protection, attackers can trick users into approving malicious OAuth clients. Use a random token stored in a secure cookie:

JavaScript
// Generate CSRF token when showing consent form
function 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 submission
function 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`,
};
}

Include the token as a hidden field in your consent form:

<input type="hidden" name="csrf_token" value="${csrfToken}" />

Input sanitization

User-controlled content (client names, logos, URIs) can execute malicious scripts if not sanitized:

JavaScript
function sanitizeText(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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 rendering
const clientName = sanitizeText(client.clientName);
const logoUrl = sanitizeText(sanitizeUrl(client.logoUri));

Content Security Policy

CSP headers instruct browsers to block dangerous content:

JavaScript
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,
};
}

State handling

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:

JavaScript
// Create state token before redirecting to upstream provider
async 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 cookie
async 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 callback
async 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);
}

Why use the __Host- prefix?

The __Host- prefix prevents subdomain attacks, which is especially important on *.workers.dev domains:

  • Must be set with Secure flag (HTTPS only)
  • Must have Path=/
  • Must not have a Domain attribute

Without __Host-, an attacker controlling evil.workers.dev could set cookies for your mcp-server.workers.dev domain.

Multiple OAuth flows

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_GOOGLE

Approved clients registry

Maintain a registry of approved client IDs per user to avoid showing the consent dialog repeatedly:

JavaScript
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`;
}

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.

Security checklist

ProtectionPurpose
CSRF tokensPrevent forged consent approvals
Input sanitizationPrevent XSS in consent dialogs
CSP headersBlock injected scripts
State bindingPrevent session fixation
__Host- cookiesPrevent subdomain attacks
HMAC signaturesVerify cookie integrity

Next steps