1. Por qué necesitamos autenticación en ChatGPT App
Empecemos por lo principal: un usuario en ChatGPT ≠ un usuario en tu servicio.
ChatGPT tiene su propia cuenta de usuario. Tu servicio tiene sus propios userId, tenantId, roles, facturación, pedidos. No hay un vínculo mágico entre ambos por defecto. Si simplemente levantaste un servidor MCP y describiste algunas tools, ChatGPT las llamará como un cliente abstracto.
Recordemos nuestro ejemplo hipotético de aplicación GiftGenius, una ChatGPT App que ayuda a elegir regalos y gestionar wishlists. Lo que queremos poder hacer:
- Mostrar al usuario sus listas de regalos guardadas.
- Permitir marcar regalos como «comprados» o «recibidos».
- Mostrar el historial de pedidos (especialmente si luego vamos a commerce/ACP).
Sin autenticación, el servidor MCP no sabe «quién es esta persona». Como mucho ve algunos identificadores técnicos de la conexión y un subject anónimo que OpenAI proporciona para identificación y rate limits, pero advierte claramente que no debe usarse para autorización.
Autenticación vs autorización
Es muy útil separar estos dos conceptos desde el principio.
- La autenticación (AuthN) responde a la pregunta: ¿quién es?
- La autorización (AuthZ) responde: ¿qué se le permite hacer a ese “alguien”?
Para una ChatGPT App el esquema es aproximadamente así:
- Primero, mediante OAuth confirmas que el usuario realmente ha iniciado sesión en tu Identity Provider (IdP) (por ejemplo, Keycloak/Auth0) y obtienes un token con su identificador. Eso es autenticación.
- Después, el servidor MCP lee el token, extrae de él sub, roles y otros claims y decide si ese usuario puede invocar una herramienta concreta (list_orders, delete_profile, etc.). Eso es autorización.
A nivel de código, se puede imaginar así (simplificado):
// Tipo de datos que el servidor MCP quiere conocer sobre el usuario
export interface AuthContext {
userId: string;
roles: string[];
}
// Ejemplo de uso en el controlador de una herramienta
async function listGiftLists(auth: AuthContext | null) {
if (!auth) {
throw new Error("User is not authenticated");
}
// Obtenemos de la base de datos solo las listas de este usuario
return db.giftLists.findMany({ where: { ownerId: auth.userId } });
}
Sin userId y roles simplemente no podrás escribir correctamente la lógica de negocio. Todo se convertirá en «una gran cuenta compartida para todos».
2. Por qué «una clave de API en .env» no es la solución
Como desarrolladores, tenemos un reflejo natural: «Creo una clave de API, la meto en .env y todo funcionará». Y, de hecho, para integraciones internas servicio–servicio las claves de API son una herramienta válida. Pero en cuanto entran en juego usuarios reales y una ChatGPT App, el enfoque «una clave para todos» se rompe.
Veamos un código típico de módulos iniciales, donde llamábamos desde MCP a nuestro backend:
// mcp/backendClient.ts
export const backendClient = new BackendClient({
baseUrl: process.env.BACKEND_URL!,
apiKey: process.env.BACKEND_API_KEY!, // una sola clave para todo ChatGPT
});
Desde el punto de vista del backend ahora todas las peticiones se ven iguales: «es la integración de ChatGPT». No hay diferencia entre Masha y Pasha. Por lo tanto:
- No se puede mostrar un “área personal”: el servidor no sabe de quién es.
- No se pueden separar permisos: «este usuario solo puede leer y este además puede comprar».
- No se pueden asociar pedidos a una persona en tu sistema principal.
En el mundo MCP esto además es inseguro. La especificación recomienda usar autenticación HTTP (Bearer, claves de API, etc.) a través de Streamable HTTP, pero subraya que el acceso real de usuarios a recursos protegidos es mejor construirlo con OAuth y tokens, no con una única clave de servicio.
Además, desde la política de OpenAI, una buena aplicación debe solicitar solo los datos que realmente necesita y dar al usuario control sobre lo que comparte con la App. Esto encaja perfectamente con el modelo de scopes de OAuth, pero no se lleva nada bien con el enfoque de «una súper clave que lo puede todo».
Por qué es mala una clave de servicio en el contexto de ChatGPT
Una clave de API de servicio expresa la identidad del servicio, no la del usuario. Con ella puedes firmar llamadas desde tu servidor MCP a servicios internos o a APIs externas (como OpenAI API), pero no puedes decir: «Este es Vasya, muéstrale su historial de pedidos».
Antiejemplo sencillo:
// Mala opción: "engaño" al usuario
async function getMyOrdersFromBackend() {
// El servidor MCP hace una llamada a /orders/me en el backend
const res = await fetch(`${BACKEND_URL}/orders/me`, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
},
});
// El backend cree que "me" es un servicio de integración, no una persona
return res.json();
}
Aunque intentes incluir artificialmente algún userId anónimo en el cuerpo de la petición, seguirá siendo una «solución artesanal». De todos modos necesitarás:
- Una forma fiable de demostrar al backend que «esto es realmente Vasya y no otra persona».
- Una forma de limitar los permisos de un usuario concreto.
- Un mecanismo de revocación (revoke) para un usuario concreto, no para todos a la vez.
Y aquí es donde entra en escena OAuth.
3. Mini-glosario: qué queremos de un sistema de inicio de sesión
Antes de saltar a la historia de OAuth, formulemos simplemente los requisitos de un sistema de autenticación «normal» para una ChatGPT App.
Necesitamos un mecanismo en el que:
- Nuestro IdP externo (Keycloak, Auth0, Hydra+Kratos, etc.) conoce al usuario real: login, email, userId, quizá tenant.
- Ese IdP emite un token de corta duración que ChatGPT puede pasar de forma segura al servidor MCP en la cabecera HTTP Authorization: Bearer <token>.
- El servidor MCP lee el token, verifica la firma, el issuer, la audience, la caducidad y los scopes, extrae sub (identificador del usuario) y, con base en ello, mapea al usuario a sus entidades (accountId, tenantId).
- Esos mismos scopes permiten gestionar finamente los permisos: un token da solo read:gifts, otro además write:gifts o checkout.
- Si el token falta o no tiene los scopes adecuados, el servidor puede devolver un error con _meta["mcp/www_authenticate"], para que ChatGPT muestre al usuario el UI de autorización y/o vuelva a obtener el token.
En definitiva, necesitamos un protocolo estándar y contrastado que haga todo esto. Spoiler: es OAuth 2.1 (y su familia).
4. Breve evolución de OAuth: de los dinosaurios a PKCE
Ahora repasemos con cuidado la evolución de OAuth, sin profundizar en los RFC, pero entendiendo por qué nos interesan los patrones modernos.
OAuth 1.0 / 1.0a: gimnasia criptográfica
Históricamente apareció primero OAuth 1.0. Permitía a los sitios web dar a otros servicios acceso a sus recursos sin compartir la contraseña del usuario (lo cual ya era algo). Pero:
- Las firmas de las peticiones eran complejas: HMAC para casi cada petición, cadenas base, normalización de parámetros.
- Había que firmar cada petición, almacenar el consumer secret y saber formar correctamente la firma.
La mayoría de desarrolladores modernos no están por la labor de repetir a mano todos esos malabarismos.
La especificación 1.0a corrigió algunas vulnerabilidades, pero la pesadez general se mantuvo.
OAuth 2.0: un framework, no «un único protocolo»
OAuth 2.0 simplificó mucho la vida: en lugar de un único esquema rígido apareció un conjunto de flujos (authorization code, implicit, resource owner password, client credentials, etc.). Esto aportó flexibilidad, pero también generó un zoo de implementaciones.
Ventajas:
- Más fácil integrar SPA, aplicaciones móviles y aplicaciones de servidor.
- Apareció una separación clara de roles: Resource Owner, Client, Resource Server, Authorization Server.
Desventajas:
- En el mundo real surgieron muchos «atajos» peligrosos. El flujo implicit (que daba el token directamente al navegador sin intercambio de código en el servidor) resultó inseguro.
- El flujo password grant (cuando el cliente envía login/contraseña del usuario a cambio de un token) contradice la filosofía de OAuth y se convirtió en un anti‑patrón.
La propia especificación dejó demasiadas opciones «a elegir», por lo que aparecieron muchas recomendaciones y buenas prácticas, que vivían en RFC y blogs separados.
OAuth 2.1: nos calmamos y pusimos orden
OAuth 2.1 es un intento de documentar las buenas prácticas que ya se habían asentado en la comunidad:
- Enfoque casi por completo en el Authorization Code Flow como opción principal.
- Uso obligatorio de PKCE (Proof Key for Code Exchange) para clientes públicos — aquellos que no pueden guardar un secreto (por ejemplo, aplicaciones móviles, SPA y… clientes ChatGPT/MCP).
- Los flujos obsoletos e inseguros como implicit y password grant se excluyen de la especificación.
- Recomendaciones de corta vida para el access token y uso de refresh tokens para sesiones más largas.
¿Por qué te importa? Porque el ecosistema en torno a MCP y ChatGPT se orienta claramente a estas buenas prácticas: el Apps SDK y la especificación MCP Authorization exigen explícitamente authorization code + PKCE, tokens de corta duración y scopes adecuados.
5. Por qué en el mundo de ChatGPT App pensamos en patrones OAuth 2.1 + PKCE
Ahora que tenemos el contexto histórico, veámoslo a través del prisma de ChatGPT y MCP.
ChatGPT como cliente público
ChatGPT (y clientes como MCP Jam) frente a tu servidor de autenticación son el típico cliente público:
- No tiene, ni puede tener, un client_secret almacenado de forma fiable.
- Se ejecuta en la infraestructura de OpenAI, que no controlas.
Por ello, la única elección sensata es Authorization Code Flow + PKCE, donde la seguridad no depende del secreto del cliente, sino de la verificación del code challenge y el code verifier.
La documentación oficial del Apps SDK indica que ChatGPT, actuando como cliente MCP, ejecuta el flujo Authorization Code + PKCE (S256) y se negará a completar la autorización si tu Authorization Server no declara soporte de PKCE en sus metadatos: code_challenge_methods_supported: ["S256"].
Cómo se ve el flujo desde el punto de vista de MCP
A muy alto nivel, pero útil imaginarlo así (secuencia para un recurso protegido):
sequenceDiagram
participant U as Usuario
participant C as ChatGPT (Cliente MCP)
participant AS as Servidor de autorización
participant RS as Servidor MCP (Recurso)
U->>C: "Muéstrame mis pedidos"
C->>RS: call_tool(list_orders) sin token
RS-->>C: Error + _meta["mcp/www_authenticate"]
C->>AS: Abre login/consentimiento (Authorization Code + PKCE)
U->>AS: Inicia sesión y otorga consentimiento (scopes)
AS-->>C: Authorization Code
C->>AS: Intercambio del código por Access Token (+PKCE verificación)
AS-->>C: Access Token (Bearer)
C->>RS: call_tool(list_orders) con Authorization: Bearer <token>
RS->>RS: Verificación de firma, issuer, audience, scopes
RS-->>C: Lista de pedidos del usuario
C-->>U: Muestra los datos
El servidor utiliza:
- Metadatos del recurso protegido (/.well-known/oauth-protected-resource) — ahí se declara como recurso e indica qué Authorization Server atiende ese recurso.
- El token que llega en la cabecera Authorization: Bearer <token>, que verifica como JWT mediante JWK o introspecciona a través del Authorization Server.
- Si el token no es válido por audience o scopes — el servidor puede rechazar la petición y devolver de nuevo el challenge WWW-Authenticate en _meta["mcp/www_authenticate"], para que ChatGPT repita la autorización con los parámetros adecuados.
Desde el punto de vista de tu código, todo esto es bastante razonable: recibes como entrada un AuthContext ya validado y trabajas con él.
Mini‑ejemplo: cómo una MCP‑tool distingue entre usuario anónimo y autenticado
Por ahora sin un OAuth SDK concreto, solo el concepto:
import type { McpToolHandler } from "./types";
export const listOrders: McpToolHandler = async (_args, context) => {
const auth = context.auth; // supongamos que aquí colocamos el resultado de la verificación del token
if (!auth) {
return {
content: [{ type: "text", text: "Debes iniciar sesión para ver los pedidos." }],
_meta: {
// Desafío para ChatGPT: inicia el flujo OAuth
"mcp/www_authenticate": [
'Bearer resource_metadata="https://mcp.giftgenius.app/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Login required to view orders"'
]
},
isError: true
};
}
const orders = await db.orders.findMany({ where: { userId: auth.userId } });
return {
content: [{ type: "text", text: `Pedidos encontrados: ${orders.length}` }],
structuredContent: orders
};
};
Precisamente esta _meta["mcp/www_authenticate"] sugerencia está descrita en la documentación oficial del Apps SDK como el disparador del UI de OAuth por parte de ChatGPT.
6. Qué significan «token de corta duración, scopes mínimos» en la práctica
De las especificaciones y guías se derivan algunos principios importantes que conviene tener en mente ya ahora, antes de la siguiente lección sobre la configuración concreta del IdP.
Vida corta del token
El access token debe vivir poco. ¿Por qué?
- Si se filtra, el atacante estará limitado en el tiempo.
- Puedes cambiar con seguridad los permisos del usuario y, al poco tiempo, el token «caduca» y se solicitará uno nuevo.
Normalmente son minutos o decenas de minutos. A cambio, obtienes refresh tokens y/o autorizaciones repetidas, pero en el contexto de ChatGPT gran parte de la rutina la asume el lado cliente.
Scopes como forma de limitar permisos
Los scopes son cadenas como gifts.read, gifts.write, orders.read, orders.checkout. Indican qué permisos tiene el usuario dentro de ese recurso.
Para una ChatGPT App esto es especialmente importante:
- Puedes emitir un token solo con gifts.read cuando el usuario solo consulta wishlists.
- Y para operaciones ACP/Instant Checkout tiene sentido solicitar un conjunto de permisos más estricto — por ejemplo, orders.checkout, y destacarlo explícitamente al usuario.
En la descripción de tools de MCP ya existe la posibilidad de declarar securitySchemes con scopes concretos para las herramientas, para que ChatGPT sepa qué permisos son necesarios para invocar cada tool.
Audience: el token debe ser «para este» recurso MCP
Otro detalle importante es aud (audience). El servidor MCP debe comprobar que el token ha sido emitido para él, y no para algún servicio vecino.
En la documentación del Apps SDK se dice claramente que ChatGPT pasará el parámetro resource y espera que el Authorization Server lo refleje en el token (normalmente en aud), y que el servidor MCP verificará ese campo.
Es muy probable que, durante el proceso de revisión de tu aplicación, le pasen auth_tokens falsos para comprobar que no hay agujeros en tu implementación de seguridad. Así que hazlo todo bien desde el principio.
7. Cómo encaja esto en nuestra aplicación GiftGenius
Volvamos a centrarnos en nuestra App de ejemplo. Ahora mismo tenemos algo así:
- Hay una MCP‑tool get_gift_ideas, que, según la descripción del destinatario y el presupuesto, propone ideas de regalos. Puede funcionar en modo anónimo.
- Hay una MCP‑tool save_gift_list, que guarda una lista en la base de datos. Queremos que esté asociada a un usuario concreto.
- Hay una MCP‑tool list_saved_lists, que muestra todas las listas guardadas por el usuario. Esto requiere autenticación sí o sí.
El widget muestra tarjetas bonitas de regalos, permite hacer clic en «guardar» y «marcar como comprado»: todo ello es, en esencia, un front para herramientas MCP protegidas.
A nivel de tipos, podría verse así:
// Tipado del contexto de invocación de la herramienta (simplificado)
interface ToolContext {
auth: AuthContext | null;
}
// Ejemplo de herramienta protegida
async function listSavedGiftLists(_input: {}, context: ToolContext) {
if (!context.auth) {
// Aquí irá el mismo truco con mcp/www_authenticate que arriba
throw new Error("Authentication required");
}
return db.giftLists.findMany({
where: { ownerId: context.auth.userId }
});
}
Y en cuanto escribes funciones así, se hace evidente: «simplemente una clave de API en .env» no ayuda en absoluto. Necesitas un AuthContext en condiciones, construido a partir de un token OAuth verificado.
Qué partes de la aplicación pueden funcionar en anónimo y cuáles no
Un buen ejercicio antes de configurar OAuth es recorrer la funcionalidad y dividirla honestamente en dos categorías.
Por ejemplo, en GiftGenius:
Anónimo:
- Generación de ideas de regalos a partir de una descripción.
- Mostrar ejemplos y modo demo con datos ficticios.
Solo para usuarios autenticados:
- Visualización y edición de wishlists personales.
- Historial de pedidos.
- Cualquier operación de pago, Instant Checkout, integración con ACP.
En las próximas lecciones configuraremos el Authorization Server (por ejemplo, Keycloak o el combo Hydra+Kratos) y el servidor MCP para que los tokens tengan los scopes adecuados para estas acciones, y las MCP‑tools sepan rechazar correctamente y pedir a ChatGPT que reautorice.
8. Errores típicos al entender la autenticación en ChatGPT App
Error nº 1: «Si ChatGPT ya conoce al usuario, ¿para qué necesito mi propio login?»
Muchos piensan: «ChatGPT tiene la cuenta del usuario, ¿por qué no usarla como userId?». Pero ChatGPT no te revela la identidad real del usuario ni te da acceso a sus cuentas. En los metadatos de MCP apenas ves un subject anónimo _meta["openai/subject"], destinado a rate limits e identificación de sesión, pero se indica explícitamente que no debe utilizarse para autorización ni para vincular a cuentas reales.
Error nº 2: «Una única clave de API para todos es normal, es solo una “integración”»
El enfoque de «cablear en el servidor MCP una clave de API a tu backend y listo» solo funciona para escenarios en los que todos los usuarios de ChatGPT comparten la misma cuenta en tu servicio. En cuanto aparecen datos personales, commerce, ACL, te topas con la imposibilidad de distinguir usuarios y gestionar sus permisos. Una clave de API es la identidad de un servicio, no del usuario.
Error nº 3: «Implementemos password grant, es lo más sencillo»
La costumbre de pasar login/contraseña del usuario a tu backend y canjearla por un token (Resource Owner Password Credentials Grant) es un patrón obsoleto e inseguro de los inicios de OAuth 2.0. En las recomendaciones modernas y en el contexto de OAuth 2.1 se considera un anti‑patrón. Los clientes públicos como ChatGPT no deben ver jamás las contraseñas de tus usuarios: para eso existe Authorization Code + PKCE.
Error nº 4: «PKCE es complejidad innecesaria, mejor sin él»
PKCE (especialmente S256) no es marketing, sino un mecanismo obligatorio de protección del Authorization Code Flow para clientes públicos. Sin PKCE, un authorization code robado puede reutilizarse. En la especificación de MCP Authorization y en el Apps SDK se indica explícitamente que ChatGPT requiere que declares soporte de PKCE en los metadatos del Authorization Server y usa este mecanismo. Si lo desactivas, el flujo simplemente no funcionará.
Error nº 5: «Pidamos todos los scopes posibles, por si acaso»
A veces apetece emitir un token con permisos «para abrirlo todo y hasta formatear C:». Pero esto viola el principio de mínimo privilegio (PoLP) y choca con las políticas de OpenAI y de la mayoría de IdP. Es mejor pensar con claridad qué scopes necesita realmente tu ChatGPT App: unos para lectura, otros para escritura y otros separados para commerce. No solo mejora la seguridad, también impacta en la UX del consentimiento: el usuario ve un conjunto de permisos comprensible y acotado, no una lista interminable de líneas crípticas.
Error nº 6: «El servidor MCP guardará logins/contraseñas y pintará el UI de login»
El servidor MCP es un Resource Server, no un Authorization Server. Debe saber verificar tokens, declarar sus metadatos .well-known y devolver challenges WWW-Authenticate, pero no encargarse del login ni almacenar contraseñas. Para login/consentimiento, mejor usar un Authorization Server especializado (Keycloak, Hydra, Auth0, etc.), como veremos en las próximas lecciones.
GO TO FULL VERSION