Patrones de autenticación MCP: OAuth, tokens y cuándo usar cada uno
La especificación Model Context Protocol (modelcontextprotocol.io) define transportes y primitivas. No define la autenticación. Con razón, porque la autenticación es cosa del transporte y del despliegue, no del protocolo. También es el origen del error de producción más habitual que veo: un servidor MCP expuesto a Internet sin autenticación, con herramientas que modifican estado. Cuando hay datos personales de por medio, se suma el artículo 32 del RGPD, que obliga a aplicar medidas técnicas apropiadas.
Este artículo forma parte del pilar sobre desarrollo de servidores MCP.
TL;DR
- La lectura de datos públicos con rate limiting es el único caso legítimo de acceso anónimo.
- Los tokens de API con scopes, guardados con hash y con TTL corto, cubren B2B y headless.
- OAuth 2.1 con PKCE cubre los asistentes de consumo que actúan en nombre de un usuario autenticado.
- El rate limiting se asocia al principal; las herramientas que modifican estado reciben un bucket más estrecho.
- Cada evento de autenticación se registra: emitido, usado, revocado, rechazado.
Cómo elegir el método de autenticación MCP
Antes de elegir un patrón de autenticación, repaso siempre las mismas cinco preguntas:
- ¿Alguna herramienta modifica estado? Si es así, el acceso anónimo queda descartado.
- ¿El agente actúa en nombre de un usuario humano concreto? Si es así, OAuth.
- ¿El agente actúa como integración B2B sin usuario humano? Si es así, token de API con scopes.
- ¿La superficie de datos es accesible desde la Internet pública? Si es así, el rate limiting es obligatorio.
- ¿Se mezclan en un mismo servidor herramientas que modifican estado y herramientas de solo lectura? Si es así, scope de autenticación por herramienta, no por servidor.
El árbol se traduce en tres patrones:
| Patrón | Cuándo usarlo | Implementación |
|---|---|---|
| Anónimo + rate limit por IP | Catálogo público, solo lectura, carga acotada | El Worker solo comprueba el bucket de la IP |
| Token de API con scopes | Integración B2B, runtime de agente headless, sin usuario humano | JWT con claims, almacenamiento con hash, rotación |
| OAuth 2.1 + PKCE | Agente de consumo para un usuario autenticado | Authorization code flow estándar |
Los patrones no se excluyen. Un servidor de producción real suele ejecutar los tres, y cada herramienta lleva la etiqueta del scope que requiere.
Acceso MCP anónimo de solo lectura con rate limiting
Una herramienta de navegación del catálogo que envuelve /wp-json/wc/v3/products?stock_status=instock expone los mismos datos que el sitio público ya muestra. Poner autenticación delante no mejora la postura de seguridad; solo reduce la disponibilidad. La amenaza real es la carga: un bucle de un agente o un scrape de la competencia puede tumbar el origen de WooCommerce.
Implementación:
async function checkAnonymousRateLimit(request: Request, env: Env): Promise<boolean> {
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const key = `rl:anon:${ip}`;
const count = Number((await env.RATE_LIMIT.get(key)) ?? "0");
if (count >= 60) return false;
await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 60 });
return true;
}60 peticiones por minuto por IP es mi valor por defecto. Un contador basado en KV es aproximado (las escrituras en KV son eventually consistent); para límites estrictos, Durable Objects o el binding nativo de Rate Limiting de Cloudflare son la herramienta adecuada.
Lo que este patrón no cubre: ninguna herramienta que devuelva datos específicos de un usuario, ninguna que modifique estado, ninguna que revele el stock de productos no públicos. Esas requieren un principal real.
Tokens de API con scopes para integraciones B2B con MCP
El caso B2B: la integración de un socio aporta un agente que llama a tu servidor MCP en nombre de la organización del socio, no de un usuario humano concreto. Ejemplos: un agente de sincronización de stock en un mayorista, una integración de marketplace en un agregador, un agente de analítica que informa de tendencias de pedidos al BI.
El flujo:
- Un administrador en el backend de WordPress crea un token con un nombre, un conjunto de scopes (
catalogue:read,inventory:read,orders:write) y una fecha de caducidad (por defecto, 90 días). - El token se muestra al administrador una sola vez y después se guarda como hash SHA-256 más el conjunto de scopes más la caducidad.
- El socio configura su cliente MCP con el token en la cabecera
Authorization: Bearer <token>. - El Worker calcula el hash del token entrante y lo busca en KV. Si el hash coincide, la caducidad está en el futuro y el scope que exige la herramienta está en el conjunto del token, la llamada continúa.
async function verifyApiToken(authHeader: string | null, env: Env): Promise<TokenContext | null> {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
const hash = await sha256(token);
const record = await env.TOKENS.get(`tok:${hash}`, "json") as TokenRecord | null;
if (!record) return null;
if (record.expiresAt < Date.now()) return null;
return { tokenId: record.id, scopes: record.scopes, principal: record.principal };
}
function requireScope(ctx: TokenContext, scope: string): void {
if (!ctx.scopes.includes(scope)) {
throw new McpError("forbidden", `Tool requires scope ${scope}`);
}
}Dos hábitos operativos mantienen el patrón honesto:
Rotación, no eternidad. Un token que nunca caduca es un token que dentro de seis meses acaba en un repositorio público de GitHub. 90 días por defecto, con un flujo de renovación en el que el token antiguo y el nuevo conviven durante 7 días, es el patrón que sobrevive a socios reales.
Almacenamiento con hash, no en texto plano. Si tu KV se filtra, los hashes sin el token original no sirven para nada. Si guardaste los tokens en texto plano, todas las integraciones de socios necesitan una rotación inmediata. La diferencia de coste en el momento de la emisión es una llamada a sha256.
OAuth 2.1 con PKCE en un servidor MCP
El caso de consumo: el usuario abre Claude Desktop, conecta su cuenta de tu tienda mediante OAuth y le pide al agente “muéstrame mi último pedido”. El agente tiene que llamar ahora a tu servidor MCP con credenciales que digan “actúo en nombre del usuario 4231”.
OAuth 2.1 (draft-ietf-oauth-v2-1) es el perfil consolidado. PKCE (RFC 7636) es obligatorio para clientes públicos (aquí entran los asistentes de escritorio sin una parte de servidor confidencial).
El flujo en cinco pasos:
- El host MCP abre el navegador en tu endpoint de autorización con
response_type=code,code_challenge=<hash S256 del verifier>y los scopes solicitados. - El usuario inicia sesión en tu sitio WordPress (o en tu proveedor de autenticación) y acepta los scopes.
- Tu endpoint de autorización redirige al host con un código de autorización de un solo uso.
- El host intercambia el código más el
code_verifieroriginal en tu endpoint de tokens por un access token (TTL corto, 1 hora) y un refresh token (TTL más largo, 30 días). - El host llama al servidor MCP con
Authorization: Bearer <access_token>. El Worker verifica la firma del token, la caducidad y el scope frente a la herramienta invocada.
El access token es un JWT firmado. El Worker lo verifica con la Web Crypto API (documentación de Cloudflare Workers) sin biblioteca externa:
async function verifyJwt(token: string, env: Env): Promise<JwtClaims | null> {
const [headerB64, payloadB64, sigB64] = token.split(".");
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const sig = base64UrlDecode(sigB64);
const valid = await crypto.subtle.verify("RS256", env.PUBLIC_KEY, sig, data);
if (!valid) return null;
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (payload.exp * 1000 < Date.now()) return null;
return payload as JwtClaims;
}Los scopes del JWT reflejan los del patrón de token con scopes: catalogue:read, orders:read, orders:write. El ID del usuario de WordPress va en el claim sub, así que una llamada orders:read devuelve solo los pedidos de ese usuario.
Cómo combinar OAuth, tokens y acceso anónimo en un servidor MCP
Un servidor MCP real para WooCommerce suele exponer:
catalogue.listyproduct.detail: anónimo + rate limit por IP.inventory.check(para socios): token con scopeinventory:read.order.status(para el usuario autenticado): OAuth conorders:read.order.intent(para el usuario autenticado): OAuth conorders:write, más un rate limit más estrecho.
La lógica de pre-dispatch en el Worker recorre los patrones:
async function authenticate(request: Request, toolName: string, env: Env): Promise<Principal> {
const requirement = TOOL_AUTH_REQUIREMENTS[toolName];
if (requirement === "anonymous") {
if (!await checkAnonymousRateLimit(request, env)) throw new McpError("rate_limit");
return { kind: "anonymous" };
}
const auth = request.headers.get("Authorization");
if (requirement === "api_token") {
const ctx = await verifyApiToken(auth, env);
if (!ctx) throw new McpError("unauthorized");
return { kind: "api_token", ctx };
}
if (requirement === "oauth") {
const claims = auth?.startsWith("Bearer ") ? await verifyJwt(auth.slice(7), env) : null;
if (!claims) throw new McpError("unauthorized");
return { kind: "oauth", claims };
}
throw new Error(`Unknown auth requirement for ${toolName}`);
}El mapa TOOL_AUTH_REQUIREMENTS es la única fuente de verdad sobre qué herramienta necesita qué modo de autenticación. Ninguna herramienta se añade sin una entrada explícita.
Límites de peticiones MCP por IP, token y usuario
El tráfico anónimo recibe un bucket por IP. El tráfico con token recibe un bucket por token. El tráfico OAuth recibe un bucket por usuario. Las herramientas que modifican estado reciben un techo más bajo sea cual sea el principal.
Para un escenario WooCommerce, mis buckets por defecto son:
| Categoría de herramienta | Anónimo | Token | OAuth |
|---|---|---|---|
catalogue.* (lectura) | 60 / minuto / IP | 600 / minuto / token | 120 / minuto / usuario |
inventory.* (lectura) | no permitido | 300 / minuto / token | no permitido |
order.status (lectura) | no permitido | 60 / minuto / token | 60 / minuto / usuario |
order.intent (escritura) | no permitido | 30 / minuto / token | 10 / minuto / usuario |
Las cifras son un punto de partida; los valores correctos salen de observar tráfico real durante dos semanas y ajustar. Lo que cuenta es la estructura.
Qué eventos de autenticación MCP registrar
Cada evento relevante para la autenticación acaba en el registro:
- Token emitido. Administrador, principal de destino, scopes, caducidad.
- Token usado. ID del token, nombre de la herramienta, principal, latencia, éxito/fallo.
- Token revocado. ID del token, quién lo revocó, por qué.
- Token rechazado. Motivo (caducado, falta scope, el hash no coincide), IP, user agent.
- Intercambio de código OAuth. ID del usuario, scopes concedidos, refresh token emitido.
- Refresh de OAuth. ID del usuario, nuevo access token, token antiguo sustituido.
Los registros van por Cloudflare Logpush a un almacenamiento a largo plazo. Una consulta de dashboard que sigue los “tokens usados en las últimas 24 horas que no se habían usado en los 90 días anteriores” detecta un probable robo de token. Una consulta de “verificaciones de token fallidas por IP” detecta intentos de credential stuffing.
Temas relacionados
Este artículo trata la autenticación. El refuerzo más amplio de WordPress está en la guía de seguridad de WordPress. La página del servicio es desarrollo de servidores MCP.
Presupuesto individual, porque el alcance de la autenticación depende de los patrones que exige tu entorno; un servidor de solo lectura en modo anónimo es un proyecto distinto de una superficie completa que emite OAuth.







