<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Identidad on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/identidad/</link><description>Recent content in Identidad on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Sat, 12 Sep 2026 09:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/identidad/index.xml" rel="self" type="application/rss+xml"/><item><title>Completar Keycloak para MCP: el recurso protegido que ningún SDK te da hecho, y la extensión empresarial que se salta el flujo entero</title><link>https://blog.lo0.es/posts/completar-keycloak-para-mcp/</link><pubDate>Sat, 12 Sep 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/completar-keycloak-para-mcp/</guid><description>&lt;blockquote>
&lt;p>Continuación de &lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">Keycloak en una plataforma de IA&lt;/a>, que dejaba señalado el hueco: los indicadores de recurso no están soportados y los metadatos de recurso protegido corresponden al servidor MCP. Este artículo trata de cerrarlo. Verificado contra la revisión 2026-07-28 de la especificación, los SDK oficiales de Python y TypeScript, Keycloak 26.7.3 y LiteLLM 1.102.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>La especificación reparte tres obligaciones y el servidor de autorización solo cubre una.&lt;/strong> El servidor MCP debe publicar sus metadatos de recurso protegido. El cliente debe mandar el indicador de recurso en las dos peticiones, y debe hacerlo aunque el servidor de autorización no lo soporte. El servidor MCP debe validar que el token se emitió para él. Keycloak no entiende el indicador y considera los metadatos ajenos, así que las tres acaban en la capa del recurso.&lt;/p>
&lt;p>&lt;strong>El SDK de Python monta los metadatos solo, pero degradados.&lt;/strong> En cuanto se configura la URL del servidor de recursos, la ruta aparece. Lo que publica esa ruta automática lleva un solo servidor de autorización, reutiliza como catálogo de ámbitos los que exige el middleware, y deja el nombre y la documentación a nulo. Para publicar un documento completo hay que montar la ruta a mano.&lt;/p>
&lt;p>&lt;strong>La validación de audiencia viene apagada en Python y no existe en TypeScript.&lt;/strong> En Python, si se configura la URL del recurso y no se activa el flag correspondiente, el SDK emite un aviso de obsolescencia y se comporta como si estuviera desactivado; el propio docstring promete que la versión 3 lo pondrá a cierto. En TypeScript la verificación del portador hace tres cosas, y ninguna es mirar la audiencia. Ninguno de los dos trae un verificador de firma con claves públicas.&lt;/p>
&lt;p>&lt;strong>El paso del token está prohibido por escrito, y el sustituto tiene nombre.&lt;/strong> La especificación dice que el servidor MCP no debe aceptar ni retransmitir tokens que no se emitieran para él, y que si llama a APIs de más arriba el token debe ser otro. El mecanismo es el intercambio de token, que Keycloak soporta en su versión estándar con una limitación que hay que conocer: la audiencia es un identificador de cliente, no una URL de recurso.&lt;/p>
&lt;p>&lt;strong>Hay una extensión oficial que cambia el dibujo entero, y está estable.&lt;/strong> La autorización gestionada por la empresa sustituye la redirección al servidor de autorización del MCP por un intercambio en el proveedor de identidad corporativo, que evalúa la política y emite una concesión basada en aserción de identidad. Es estable desde junio de 2026 y hay servidores en producción.&lt;/p>
&lt;p>&lt;strong>Keycloak la implementa a medias y en experimental.&lt;/strong> Solo sabe actuar como receptor, no como emisor, detrás de una bandera de función, y contra el borrador 01 cuando el grupo de trabajo va por el 04. La documentación oficial dice literalmente que no se use en producción.&lt;/p>
&lt;p>&lt;strong>LiteLLM ya la implementa del lado cliente y no lo documenta.&lt;/strong> En 1.102.0 hay un modo de autenticación hacia servidores MCP que ejecuta las dos etapas de esa concesión. No hay un solo fichero de documentación que lo mencione.&lt;/p>
&lt;h2 id="estás-aquí-el-lado-del-recurso">Estás aquí: el lado del recurso&lt;/h2>
&lt;p>El artículo anterior recorrió el proveedor de identidad. Este recorre lo que hay al otro lado del token, que es donde la especificación de MCP pone casi todas sus obligaciones normativas.&lt;/p>
&lt;p>El orden de lectura natural es &lt;a href="https://blog.lo0.es/posts/mcp-crece-autenticacion-keycloak/">Cuando el MCP crece&lt;/a> para el montaje básico, &lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">Keycloak en una plataforma de IA&lt;/a> para la pieza de identidad, y este para la capa que falta. El gateway MCP visto desde dentro está &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">en su propio artículo&lt;/a>.&lt;/p>
&lt;h2 id="la-analogía-el-visado-y-el-control-de-frontera">La analogía: el visado y el control de frontera&lt;/h2>
&lt;p>Un consulado emite visados. Un puesto fronterizo los comprueba. Son dos oficinas distintas, y el error clásico consiste en suponer que porque el visado sea auténtico sirve para entrar por cualquier puerta.&lt;/p>
&lt;p>El visado lleva escrito para qué país vale. Esa es la audiencia del token, y aquí aparece el problema: el consulado con el que trabajamos no sabe escribir el destino que pide el viajero, porque no entiende ese campo del formulario. Sabe escribir un destino si se le pide con otro nombre, mediante un sello preacordado, que es el rodeo de los ámbitos y el mapeador de audiencia.&lt;/p>
&lt;p>Y queda la otra mitad, que es la que casi nadie monta. El puesto fronterizo tiene que existir, tiene que anunciar dónde está y de qué consulados acepta visados, y tiene que leer el destino escrito en el visado antes de dejar pasar. Si el puesto fronterizo se limita a comprobar que el sello es auténtico y no mira el destino, cualquier visado válido del mismo consulado sirve para entrar. Eso es exactamente lo que hacen por defecto los dos SDK oficiales.&lt;/p>
&lt;h2 id="parte-1-el-reparto-de-obligaciones">Parte 1. El reparto de obligaciones&lt;/h2>
&lt;p>De la revisión 2026-07-28, con las palabras normativas tal cual:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Obligación&lt;/th>
&lt;th>De quién&lt;/th>
&lt;th>Estado con Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Publicar metadatos de recurso protegido (RFC 9728)&lt;/td>
&lt;td>Servidor MCP, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Fuera de su alcance, por diseño&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mandar el indicador de recurso (RFC 8707) en autorización y en token&lt;/td>
&lt;td>Cliente, &lt;strong>MUST&lt;/strong>, incluso si el servidor de autorización no lo soporta&lt;/td>
&lt;td>No lo entiende&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validar que el token se emitió para uno mismo&lt;/td>
&lt;td>Servidor MCP, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Responsabilidad del recurso&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No aceptar ni retransmitir otros tokens&lt;/td>
&lt;td>Servidor MCP, &lt;strong>MUST NOT&lt;/strong>&lt;/td>
&lt;td>Responsabilidad del recurso&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validar el emisor de la respuesta de autorización (RFC 9207)&lt;/td>
&lt;td>Cliente, &lt;strong>MUST&lt;/strong>; servidor de autorización &lt;strong>SHOULD&lt;/strong> emitirlo&lt;/td>
&lt;td>Soportado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consentimiento por cada cliente registrado dinámicamente&lt;/td>
&lt;td>Proxy MCP con identificador estático, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Configurable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Lo que más despista de ese reparto es la segunda línea. La especificación obliga al cliente a mandar el parámetro &lt;strong>con independencia de que el servidor de autorización lo soporte&lt;/strong>. Con Keycloak, ese parámetro se pierde: la documentación oficial dice que no puede reconocerlo, y el comportamiento estándar ante un parámetro desconocido es ignorarlo. El cliente cumple, el token sale, y lo que no sale es la audiencia correcta. El fallo aparece más tarde y en otro sitio, que es el peor tipo de fallo.&lt;/p>
&lt;p>Conviene saber dónde está ese trabajo. El asunto es la incidencia 14355 del proyecto, abierta, con hito en la 26.8.0. Hubo una implementación completa en la petición de cambios 35711, con mapeador propio y un punto de extensión para resolver recursos, que &lt;strong>pasó a borrador en octubre de 2025&lt;/strong> por falta total de pruebas y porque se decidió rehacerla por fases, empezando por un solo recurso por cliente. En marzo de 2026 se abrió una incidencia nueva para soporte experimental, también con hito en la 26.8.0 y sin petición de cambios asociada. En 26.7.x no hay bandera de función que lo active.&lt;/p>
&lt;h3 id="novedades-de-la-revisión-que-afectan-al-montaje">Novedades de la revisión que afectan al montaje&lt;/h3>
&lt;p>Cuatro cosas cambiaron respecto a la revisión anterior y conviene recogerlas:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RFC 9207.&lt;/strong> Sección nueva de validación de la respuesta de autorización. El cliente debe registrar el emisor del documento de metadatos validado, y la comparación es literal: no se permite normalizar mayúsculas, elidir el puerto por defecto, añadir o quitar la barra final ni recodificar caracteres antes de comparar.&lt;/li>
&lt;li>&lt;strong>Los tokens sin conexión salen del catálogo.&lt;/strong> Sección nueva sobre tokens de refresco: los servidores MCP &lt;strong>no deberían&lt;/strong> incluir el ámbito correspondiente ni en la cabecera de reto ni en los ámbitos soportados de sus metadatos.&lt;/li>
&lt;li>&lt;strong>El registro dinámico queda obsoleto.&lt;/strong> Pasa de opcional a opcional y deprecado, retenido por compatibilidad con servidores de autorización que no soporten documentos de metadatos de identificador de cliente. Y se añade un requisito nuevo al registro: el tipo de aplicación es obligatorio, y un cliente no debe reutilizar credenciales de otro servidor de autorización, tiene que registrarse de nuevo.&lt;/li>
&lt;li>&lt;strong>La elevación por pasos se reescribe.&lt;/strong> Desaparece el menú de tres estrategias de servidor y se sustituye por dos reglas: el atributo de ámbito del reto describe lo que hace falta para el recurso pedido, sin obligación de incluir lo ya concedido, y la acumulación de ámbitos pasa a ser responsabilidad del cliente. Con un requisito nuevo para el servidor: &lt;strong>debe tener en cuenta las jerarquías de ámbitos&lt;/strong>, donde uno amplio implica los estrechos.&lt;/li>
&lt;/ul>
&lt;h2 id="parte-2-lo-que-los-sdk-dan-y-lo-que-no">Parte 2. Lo que los SDK dan, y lo que no&lt;/h2>
&lt;p>Aquí está la parte incómoda, y es la razón principal para escribir este artículo. Verificado leyendo el código del SDK de Python y del de TypeScript 2.0.0-alfa.&lt;/p>
&lt;h3 id="los-metadatos-de-recurso-protegido">Los metadatos de recurso protegido&lt;/h3>
&lt;p>&lt;strong>En Python existe el modelo completo y se monta solo.&lt;/strong> La clase de metadatos lleva los campos de la RFC: recurso, servidores de autorización con un mínimo de uno, URL del juego de claves, ámbitos soportados, métodos de portador con valor por defecto de cabecera, nombre, documentación, política, condiciones, y los campos de certificado de cliente y de DPoP. El manejador sirve el documento con una directiva de caché de una hora, y la URL se construye insertando la ruta conocida delante del camino del recurso, como pide la RFC.&lt;/p>
&lt;p>En cuanto se configura la URL del servidor de recursos, la ruta aparece sin más trabajo. &lt;strong>Pero lo que publica esa ruta automática está degradado en tres puntos&lt;/strong>: pasa un único servidor de autorización, toma como catálogo de ámbitos los que el middleware exige (que no son lo mismo que los que el recurso soporta), y no pasa ni el nombre ni la documentación, que salen a nulo. Para publicar un documento completo hay que llamar a la función de creación de rutas a mano.&lt;/p>
&lt;p>&lt;strong>En TypeScript existe y no se monta solo.&lt;/strong> La función que construye el documento emite &lt;strong>solo cinco campos&lt;/strong>: recurso, servidores de autorización, ámbitos soportados, nombre y documentación. &lt;strong>No emite los métodos de portador soportados&lt;/strong>, ni la URL del juego de claves, ni nada de DPoP. Y servirlo es explícito: o se llama a la función de respuesta desde el manejador propio, o se monta el enrutador de metadatos. No hay ningún punto del SDK que lo haga por su cuenta.&lt;/p>
&lt;h3 id="la-validación-de-audiencia">La validación de audiencia&lt;/h3>
&lt;p>Esta es la que hay que corregir el primer día.&lt;/p>
&lt;p>&lt;strong>En Python viene desactivada.&lt;/strong> El verificador de tokens es un protocolo de un solo método, es decir un hueco que rellena el implementador. El campo de recurso del token de acceso es el indicador que uno mismo pone. Y la comparación solo ocurre si se pide: hay una función que normaliza como URL e ignora la barra final, pero la URL del servidor de recursos le llega vacía salvo que se active el flag de validación.&lt;/p>
&lt;p>El detalle que hay que leer dos veces está en la configuración: &lt;strong>si la URL del recurso está puesta y el flag no, el SDK lanza un aviso de obsolescencia y se comporta como si el flag fuera falso&lt;/strong>. El docstring dice que la versión 3 pondrá el valor por defecto a cierto. Hasta entonces, un servidor Python configurado con autorización acepta tokens emitidos para otro recurso, salvo que el implementador active el flag o valide la audiencia dentro de su propio verificador.&lt;/p>
&lt;p>&lt;strong>En TypeScript no se valida en absoluto.&lt;/strong> La verificación del token portador hace exactamente tres cosas: separar el prefijo, comprobar los ámbitos requeridos y exigir que la expiración esté presente y no vencida. Una búsqueda de audiencia o de juego de claves sobre los paquetes de servidor y de middleware no devuelve ninguna validación. Todo queda en manos del verificador que se enchufe.&lt;/p>
&lt;p>&lt;strong>Ninguno de los dos trae un verificador de firma con claves públicas.&lt;/strong> En Python el único ejemplo es introspección contra el servidor de autorización, y su comprobación de audiencia está detrás de un flag que también viene a falso. En TypeScript la interfaz está vacía. Es decir: el componente que valida el token, que es el que sostiene el requisito normativo más importante de la especificación, es código propio en los dos casos.&lt;/p>
&lt;h3 id="el-reto-de-autenticación">El reto de autenticación&lt;/h3>
&lt;p>&lt;strong>Python&lt;/strong> construye el reto con el error y su descripción, y añade la URL de los metadatos solo si está configurada. Devuelve 401 con token inválido y 403 con ámbito insuficiente, ambos por la misma función, así que los dos llevan la URL de metadatos. &lt;strong>Lo que no emite nunca es el parámetro de ámbito&lt;/strong>, que la revisión 2026-07-28 pide que se incluya, y que es justo lo que el cliente necesita para saber qué pedir.&lt;/p>
&lt;p>&lt;strong>TypeScript sí emite el ámbito&lt;/strong> cuando hay ámbitos requeridos, además de la URL de metadatos, y mapea correctamente el token inválido a 401 y el ámbito insuficiente a 403.&lt;/p>
&lt;p>Es decir, cada SDK tiene bien una mitad distinta. El de Python valida mejor y avisa peor; el de TypeScript avisa mejor y no valida.&lt;/p>
&lt;h3 id="los-ámbitos-que-no-son-por-herramienta">Los ámbitos, que no son por herramienta&lt;/h3>
&lt;p>En los dos SDK los ámbitos son &lt;strong>una lista estática a nivel de montaje del transporte&lt;/strong>, no por herramienta. No hay declaración de ámbito por herramienta ni ninguna ayuda de elevación por pasos del lado servidor. Emitir un 403 con el ámbito concreto que exige esa llamada, que es lo que la especificación describe, es código propio.&lt;/p>
&lt;p>Ahí está el origen de casi toda la autorización fina que hay que construir, y por eso la Parte 6.&lt;/p>
&lt;h3 id="qué-revisión-anuncia-cada-uno">Qué revisión anuncia cada uno&lt;/h3>
&lt;p>&lt;strong>Python ya está en 2026-07-28.&lt;/strong> La revisión aparece en las versiones conocidas y en la lista de versiones modernas, descritas como las que usan el sobre sin estado por petición. El método de descubrimiento del servidor está registrado con manejador por defecto, y las cabeceras de método y nombre se validan contra el cuerpo.&lt;/p>
&lt;p>&lt;strong>TypeScript la tiene, pero en una lista aparte.&lt;/strong> La constante de última versión de protocolo sigue en 2025-11-25, porque esa lista es solo la del saludo inicial. La era moderna vive en un módulo separado, con un comentario que explica la razón: mantenerlas deliberadamente separadas para que añadir una revisión ahí nunca filtre una cadena de versión moderna a un saludo de la era 2025.&lt;/p>
&lt;p>Y conviene recordar el desajuste que ya salió &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">en el artículo del gateway MCP&lt;/a>: LiteLLM 1.102.0 sigue anunciando 2025-06-18.&lt;/p>
&lt;h2 id="parte-3-construir-el-recurso-protegido">Parte 3. Construir el recurso protegido&lt;/h2>
&lt;p>Con lo anterior, la lista de lo que hay que escribir queda corta y concreta.&lt;/p>
&lt;p>&lt;strong>1. Un verificador de token que valide la firma y la audiencia.&lt;/strong> Contra el juego de claves del realm, comprobando emisor, expiración y que la audiencia contiene la URL canónica del servidor MCP y &lt;strong>solo&lt;/strong> cosas que le conciernen. Aquí entra el efecto secundario del rodeo de Keycloak: si un cliente pide dos ámbitos de dos recursos distintos, sale un token con dos audiencias, porque cada mapeador aporta la suya al array. No hay condición ni ejecutor de política de cliente en 26.7 que limite el número de audiencias por token. La defensa práctica es del recurso: rechazar tokens cuya audiencia incluya recursos ajenos, en lugar de limitarse a comprobar que la propia está presente.&lt;/p>
&lt;p>&lt;strong>2. El documento de metadatos completo&lt;/strong>, con todos los servidores de autorización que se acepten, el catálogo real de ámbitos que el recurso entiende, y el nombre y la documentación rellenos. En Python, montando la ruta a mano en lugar de dejar la automática. En TypeScript, montándola, a secas.&lt;/p>
&lt;p>&lt;strong>3. El reto de autenticación con el ámbito.&lt;/strong> En Python hay que añadirlo, porque el SDK no lo emite. Y hay que tener en cuenta las jerarquías de ámbitos, que es requisito nuevo de la revisión.&lt;/p>
&lt;p>&lt;strong>4. La decisión entre validación local e introspección.&lt;/strong> Validar la firma en local no cuesta red, pero &lt;strong>la revocación no surte efecto hasta que el token expira&lt;/strong>: no hay listas de revocación para tokens firmados. La introspección cuesta una ida y vuelta por petición y solo la pueden invocar clientes confidenciales. La combinación razonable es vida de token corta y introspección en las operaciones que cambien estado.&lt;/p>
&lt;p>&lt;strong>5. El enlace del estado al usuario.&lt;/strong> La revisión nueva es explícita sobre esto, porque al desaparecer las sesiones de protocolo el estado se lleva en manejadores que viajan como argumento ordinario de herramienta. Los servidores &lt;strong>deben&lt;/strong> verificar toda petición entrante y &lt;strong>no deben&lt;/strong> tratar la posesión de un manejador como autenticación; y &lt;strong>deberían&lt;/strong> atar el manejador al usuario del lado servidor, por ejemplo guardando el estado con una clave que combine el identificador de usuario derivado del token verificado con el manejador, y rechazar el manejador si lo presenta otro. Es un cambio de forma de trabajar respecto a las sesiones de antes.&lt;/p>
&lt;h2 id="parte-4-el-salto-del-gateway-al-servidor-mcp">Parte 4. El salto del gateway al servidor MCP&lt;/h2>
&lt;p>La figura real en una plataforma de inferencia no es cliente contra servidor MCP: es cliente contra gateway, y gateway contra servidor MCP. Ese segundo salto es donde se decide si la arquitectura es correcta.&lt;/p>
&lt;p>La especificación no deja margen. El servidor MCP &lt;strong>no debe&lt;/strong> aceptar ni retransmitir tokens que no se emitieran para él. Y si hace peticiones a APIs de más arriba, puede actuar como cliente OAuth de ellas, pero &lt;strong>el token que use ahí es otro token, emitido por el servidor de autorización de arriba&lt;/strong>, y no debe reenviar el que recibió.&lt;/p>
&lt;p>El mecanismo estándar para eso es el intercambio de token, y aquí hay que conocer tres detalles de Keycloak.&lt;/p>
&lt;p>&lt;strong>Primero, el modelo de permisos cambió.&lt;/strong> La versión antigua exigía permisos de administración finos y una autorización explícita de intercambio sobre el cliente destino. La versión estándar, soportada desde la 26.2, no los exige: basta con que el cliente solicitante sea confidencial y tenga activado el interruptor correspondiente. A cambio hay una condición que sorprende: &lt;strong>el token del sujeto tiene que llevar al cliente solicitante en su audiencia&lt;/strong>, salvo que intercambie su propio token. Es decir, el gateway necesita aparecer en la audiencia del token de usuario, lo que se consigue con un mapeador de audiencia en un ámbito por defecto del gateway. Solo entonces puede reducir la audiencia a la del servidor MCP concreto.&lt;/p>
&lt;p>&lt;strong>Segundo, la audiencia es un identificador de cliente, no una URL de recurso.&lt;/strong> El parámetro filtra audiencias, es decir reduce, que es lo que se quiere. Pero toma el identificador de un cliente registrado en el realm. La consecuencia práctica es que &lt;strong>el servidor MCP tiene que estar dado de alta como cliente, y su identificador debería ser su URL canónica&lt;/strong>, para que la audiencia resultante coincida con lo que el recurso valida. Es un truco de nomenclatura, y hay que documentarlo para quien venga detrás. La propia documentación lo reconoce: el intercambio de token todavía no soporta el parámetro de recurso.&lt;/p>
&lt;p>&lt;strong>Tercero, el tipo de token del sujeto está limitado.&lt;/strong> La versión estándar solo acepta tokens de acceso como sujeto.&lt;/p>
&lt;p>Hay además una función experimental nueva en la 26.7 que añade un tipo de ámbito parametrizado para validar si el usuario solicitante está autorizado a actuar en nombre de otro. Interesante para delegación, pero experimental.&lt;/p>
&lt;h3 id="lo-que-hace-hoy-el-gateway">Lo que hace hoy el gateway&lt;/h3>
&lt;p>En LiteLLM 1.102.0 los modos de autenticación hacia servidores MCP son doce, con valor por defecto ninguno. Los que importan para esta discusión son cuatro.&lt;/p>
&lt;p>&lt;code>oauth2_token_exchange&lt;/code> implementa el intercambio estándar. Manda el tipo de concesión correcto, el token del sujeto y su tipo, y &lt;strong>la audiencia, nunca el recurso&lt;/strong>. La omisión es deliberada según el propio código: fabricar un destino arriesga un error de destino inválido. Cachea el token resultante con una clave que combina el token del sujeto con toda la configuración, y con vida útil igual a la del token menos un minuto. Y &lt;strong>bloquea, nunca degrada&lt;/strong>: sin token entrante devuelve 401, que el borde convierte en un reto con la URL de metadatos de recurso; un rechazo del proveedor de identidad devuelve 401; un error de configuración del gateway devuelve 500; un fallo de transporte, 503.&lt;/p>
&lt;p>&lt;code>true_passthrough&lt;/code> y &lt;code>oauth_delegate&lt;/code> reenvían la cabecera de autorización del cliente tal cual. Eso es paso de token, con el nombre que le da la especificación, y su uso legítimo es estrecho: cuando el token que el cliente presenta ya se emitió para el servidor MCP de destino y el gateway es un mero transporte. Fuera de ese caso, incumple.&lt;/p>
&lt;p>&lt;code>oauth2_id_jag&lt;/code> es la sorpresa, y merece su propia parte.&lt;/p>
&lt;h2 id="parte-5-la-extensión-empresarial-que-cambia-el-dibujo">Parte 5. La extensión empresarial, que cambia el dibujo&lt;/h2>
&lt;p>En junio de 2026 el proyecto MCP publicó una extensión de autorización que resuelve un problema distinto del que resuelve el flujo estándar, y que en una organización con proveedor de identidad propio es el problema de verdad.&lt;/p>
&lt;p>El flujo normal es de usuario: cada empleado autoriza cada cliente contra cada servidor MCP. Funciona para aplicaciones de consumo y no funciona en una empresa, porque el alta de una persona exige autorizar decenas de servicios uno a uno y la baja exige revocarlos uno a uno.&lt;/p>
&lt;p>&lt;strong>La extensión &lt;code>io.modelcontextprotocol/enterprise-managed-authorization&lt;/code> invierte eso.&lt;/strong> Está en el directorio de especificaciones estables del repositorio de extensiones, viene del SEP-990, y el flujo es este:&lt;/p>
&lt;ol>
&lt;li>El cliente MCP autentica al usuario contra el proveedor de identidad corporativo por el flujo normal, y &lt;strong>guarda la aserción de identidad&lt;/strong>, que puede ser un token de identidad de OpenID o una aserción SAML.&lt;/li>
&lt;li>Cuando el servidor indica que hace falta autorización gestionada por la empresa, el cliente &lt;strong>intercambia esa aserción en el proveedor corporativo por una concesión de autorización basada en aserción de identidad&lt;/strong>. El proveedor evalúa ahí la política de la organización: pertenencia a grupos, roles, acceso condicional.&lt;/li>
&lt;li>El cliente presenta esa concesión al servidor de autorización del MCP y obtiene un token de acceso.&lt;/li>
&lt;li>La frase que define la extensión, literal: &lt;strong>no se redirige al usuario al endpoint de autorización del servidor de autorización del MCP&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>El servidor de autorización del MCP valida la firma contra el juego de claves del proveedor corporativo, más audiencia, emisor y expiración, y usa el sujeto como identificador estable del usuario, con el correo como alternativa para enlazar cuentas anteriores.&lt;/p>
&lt;p>Lo que eso compra es exactamente lo que pide un expediente de cumplimiento: política en un solo sitio, decisión auditable en el proveedor de identidad, y &lt;strong>revocación centralizada que surte efecto en todos los clientes a la vez&lt;/strong>. El empleado que pierde el acceso deja de recibir concesiones, sin tocar ningún servidor.&lt;/p>
&lt;p>El estándar de debajo es un borrador del grupo de trabajo de OAuth del IETF, con el nombre de concesión de autorización por aserción de identidad en JWT, revisión 04 de mayo de 2026, firmado por gente de Okta, Ping Identity y un autor independiente. Perfila el encadenamiento de identidad entre dominios de confianza combinando el intercambio de token con el perfil de JWT.&lt;/p>
&lt;h3 id="y-aquí-viene-el-problema">Y aquí viene el problema&lt;/h3>
&lt;p>&lt;strong>Keycloak lo implementa solo a medias.&lt;/strong> Tiene página propia en la documentación, detrás de una bandera de función. Y tres limitaciones que hay que tener delante antes de diseñar nada:&lt;/p>
&lt;ul>
&lt;li>Solo actúa como receptor. Acepta aserciones emitidas por un proveedor externo y emite tokens locales. El soporte nativo para actuar como emisor, dice la documentación, todavía no está completamente implementado. En el flujo de la extensión, el emisor es el proveedor corporativo: si ese proveedor es Keycloak, la pieza que falta es justo la que hace falta.&lt;/li>
&lt;li>Está basado en el borrador 01 mientras el grupo de trabajo va por el 04.&lt;/li>
&lt;li>La documentación oficial dice que no se use en producción.&lt;/li>
&lt;/ul>
&lt;p>La asimetría es llamativa y vale la pena señalarla: Keycloak no soporta un estándar con ocho años de publicado como son los indicadores de recurso, y sí soporta parcialmente un borrador de hace meses.&lt;/p>
&lt;h3 id="lo-que-ya-está-en-el-gateway-sin-documentar">Lo que ya está en el gateway, sin documentar&lt;/h3>
&lt;p>En LiteLLM 1.102.0, el modo &lt;code>oauth2_id_jag&lt;/code> ejecuta las dos etapas del flujo. En la primera pide al proveedor corporativo un intercambio de token con el tipo de token solicitado propio de esta concesión, mandando el token de identidad del usuario como sujeto y, opcionalmente, audiencia, &lt;strong>recurso&lt;/strong> y ámbito. En la segunda presenta la aserción resultante al servidor de autorización del recurso con el tipo de concesión de portador de JWT. En las dos el gateway se autentica como cliente con JWT firmado por clave privada.&lt;/p>
&lt;p>Dos avisos operativos que salen del código y que no están en ningún sitio más:&lt;/p>
&lt;ul>
&lt;li>El sujeto sale del token de identidad del llamante, o del que se capturó en el inicio de sesión. Y solo el proveedor OIDC genérico captura aserciones: con Google, Microsoft o SAML no hay ninguna, y &lt;strong>todos los usuarios fallan&lt;/strong>. El código emite un aviso al cargar la configuración.&lt;/li>
&lt;li>Un servidor mal configurado rehúsa con 500 en lugar de caer a la credencial estática. Es la decisión correcta, y conviene saberla antes de que pase.&lt;/li>
&lt;/ul>
&lt;p>Y el aviso principal: &lt;strong>no hay documentación&lt;/strong>. Ni un fichero en el repositorio que mencione este modo. Quien lo use está leyendo código, como este artículo.&lt;/p>
&lt;h2 id="parte-6-la-autorización-por-herramienta">Parte 6. La autorización por herramienta&lt;/h2>
&lt;p>Ninguna de las piezas anteriores responde a la pregunta que acaba haciendo el negocio: si esta persona puede llamar a esta herramienta con estos argumentos. El proveedor de identidad da roles y ámbitos, y con eso no se modela un grafo de relaciones, como ya se argumentó &lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">en el artículo anterior&lt;/a>.&lt;/p>
&lt;p>Lo que hay publicado hoy, con fuente:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenFGA&lt;/strong> es el único con guía de producto dedicada, actualizada el 9 de septiembre de 2026. El patrón es un tipo de herramienta con una relación de invocación, comprobada en cada llamada, y una consulta de listado para que el cliente solo vea las herramientas que puede invocar. Es patrón y lenguaje de modelado, no implementación de referencia.&lt;/li>
&lt;li>&lt;strong>Pomerium&lt;/strong> trae un criterio de política por herramienta, con coincidencia exacta, por prefijo, por sufijo y por lista.&lt;/li>
&lt;li>&lt;strong>Kong&lt;/strong> lo ofrece como producto, con listas de control por herramienta.&lt;/li>
&lt;li>&lt;strong>Traefik Hub&lt;/strong> llega hasta restricciones a nivel de parámetro, que es el grado más fino publicado.&lt;/li>
&lt;li>&lt;strong>Envoy&lt;/strong> tiene un filtro MCP que extrae atributos del protocolo para control de acceso fino, declarado en desarrollo activo y sin OAuth propio: el gancho es la autorización externa hacia un motor de políticas.&lt;/li>
&lt;/ul>
&lt;p>La opción de menor fricción en una plataforma que ya tiene malla de servicio es la autorización externa contra un motor de políticas. La de mayor expresividad, el modelo de relaciones.&lt;/p>
&lt;h2 id="parte-7-lo-que-sigue-sin-estándar">Parte 7. Lo que sigue sin estándar&lt;/h2>
&lt;p>Dos huecos que no cubre la especificación y que hay que cerrar por fuera.&lt;/p>
&lt;p>&lt;strong>El cambio de definición de herramienta.&lt;/strong> Ni la revisión 2026-07-28 ni los SDK fijan hash de la descripción ni del esquema de entrada. La página de buenas prácticas cubre delegado confuso, paso de token, falsificación de petición del lado servidor en el descubrimiento, secuestro de manejadores de estado, confusión de servidores y validación del esquema de la URL de autorización, pero &lt;strong>no hay ningún requisito de integridad ni de fijación&lt;/strong>. La herramienta que lo hace es el escáner de Invariant Labs, con licencia Apache 2.0, que fija hashes para detectar el cambio de definición tras la aprobación y trae además un modo proxy con guardarraíles. Ya salió &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">en el artículo del gateway MCP&lt;/a> y sigue siendo la única respuesta.&lt;/p>
&lt;p>&lt;strong>La guía pública que sí entra en esto&lt;/strong> es la de la NSA sobre consideraciones de diseño de seguridad en MCP, de mayo de 2026, hecha con el instituto de ingeniería de software de Carnegie Mellon. Dos observaciones suyas son directamente accionables: que la autorización en MCP es opcional y que la especificación no impone ningún requisito de gestión del ciclo de vida de los tokens, de modo que expiración y rotación quedan en manos de la organización; y la advertencia contra el descubrimiento dinámico de herramientas sin verificación de origen ni comprobaciones de autorización, que es el reconocimiento más directo del riesgo en una guía estatal.&lt;/p>
&lt;h2 id="parte-8-quién-cubre-qué">Parte 8. Quién cubre qué&lt;/h2>
&lt;p>Con todo lo anterior, la tabla de decisión de la capa que se pone delante del servidor MCP:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Pieza&lt;/th>
&lt;th>Publica metadatos de recurso&lt;/th>
&lt;th>Valida audiencia&lt;/th>
&lt;th>Intercambio de token&lt;/th>
&lt;th>Autorización por herramienta&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SDK Python, ruta automática&lt;/td>
&lt;td>Sí, degradado&lt;/td>
&lt;td>&lt;strong>No por defecto&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SDK TypeScript&lt;/td>
&lt;td>No se monta solo&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traefik Hub (comercial)&lt;/td>
&lt;td>Sí, automático&lt;/td>
&lt;td>No documentado&lt;/td>
&lt;td>No documentado&lt;/td>
&lt;td>Sí, hasta parámetro&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong, plugin de OAuth para MCP&lt;/td>
&lt;td>No documentado&lt;/td>
&lt;td>&lt;strong>Sí&lt;/strong>&lt;/td>
&lt;td>No documentado&lt;/td>
&lt;td>Sí, listas por herramienta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pomerium&lt;/td>
&lt;td>Parcial&lt;/td>
&lt;td>No documentado&lt;/td>
&lt;td>No documentado&lt;/td>
&lt;td>Sí, criterio por herramienta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>agentgateway (Solo.io)&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Sí, por política JWT&lt;/td>
&lt;td>Sí, incluida la concesión de aserción&lt;/td>
&lt;td>Sí, con expresiones&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>mcp-context-forge (IBM)&lt;/td>
&lt;td>No en notas de versión&lt;/td>
&lt;td>No documentado&lt;/td>
&lt;td>Sí, desde 1.0.6&lt;/td>
&lt;td>Sí, control por rol&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy, filtro MCP&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Vía autorización externa&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM 1.102.0&lt;/td>
&lt;td>Sí, y también de servidor de autorización&lt;/td>
&lt;td>&lt;strong>No sobre el JWT entrante&lt;/strong>&lt;/td>
&lt;td>Sí, y concesión de aserción&lt;/td>
&lt;td>Sí, permisos por clave y equipo&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dos avisos sobre esa tabla. El plugin de Kong exige versión mínima 3.12, está en vista previa técnica y necesita licencia de su edición de IA; además introduce un cambio incompatible en 3.13, que pasa a tratar todo el tráfico como MCP para cerrar un posible salto de autenticación. Y de oauth2-proxy no encontré documentación primaria ni a favor ni en contra del modo de recurso protegido: no afirmo que no lo tenga, afirmo que no está documentado donde pude mirar.&lt;/p>
&lt;h2 id="configuración-de-referencia">Configuración de referencia&lt;/h2>
&lt;p>&lt;strong>Keycloak: el ámbito por recurso, opcional, con su audiencia.&lt;/strong> Que sea opcional y no por defecto es la pieza clave, porque es lo que hace que el cliente tenga que pedirlo explícitamente y funcione como sustituto del indicador de recurso:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp:inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y el servidor MCP dado de alta como cliente &lt;strong>con su URL canónica por identificador&lt;/strong>, para que el intercambio de token produzca la audiencia correcta:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create clients -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">clientId&lt;/span>&lt;span class="o">=&lt;/span>https://mcp.ejemplo.es/inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">enabled&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> -s &lt;span class="nv">publicClient&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&lt;/span> -s &lt;span class="nv">consentRequired&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>El gateway, con intercambio de token en lugar de paso de token:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oauth2_token_exchange&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm-gateway&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/GW_SECRET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://sso.ejemplo.es/realms/plataforma/protocol/openid-connect/token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">audience&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subject_token_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;urn:ietf:params:oauth:token-type:access_token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_profile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;rfc8693&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>El verificador del recurso, que es lo que nadie da hecho.&lt;/strong> Lo esencial es la última comprobación, la que rechaza audiencias ajenas en lugar de conformarse con encontrar la propia:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">RECURSO&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">verificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">AccessToken&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">jwks&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">audience&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># valida que estoy yo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aud&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">aud&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># y que no hay nadie más: el rodeo de los ámbitos permite&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># tokens con dos audiencias, y eso reabre el delegado confuso&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">CLIENTE_GATEWAY&lt;/span>&lt;span class="p">}:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">AccessToken&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;azp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">expires_at&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;exp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y al montar el servidor, el flag que no viene puesto:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">auth&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AuthSettings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource_server_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">validate_token_resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># sin esto solo hay un warning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required_scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mcp:inventario&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Activar la validación de audiencia en el SDK de Python, o escribirla en el verificador si es TypeScript. Sin eso, el requisito normativo central no se cumple.&lt;/li>
&lt;li>Rechazar tokens con audiencias ajenas, no solo comprobar que la propia está presente.&lt;/li>
&lt;li>Montar el documento de metadatos a mano, con todos los servidores de autorización y el catálogo real de ámbitos.&lt;/li>
&lt;li>Añadir el parámetro de ámbito al reto, en Python, y contemplar jerarquías de ámbitos.&lt;/li>
&lt;li>Dar de alta cada servidor MCP como cliente de Keycloak con su URL canónica por identificador, para que el intercambio de token produzca la audiencia correcta.&lt;/li>
&lt;li>Sustituir cualquier modo de paso de token por intercambio de token, salvo el caso estrecho en que el token ya se emitió para el destino.&lt;/li>
&lt;li>Comprobar que el gateway aparece en la audiencia del token de usuario, o el intercambio fallará.&lt;/li>
&lt;li>Atar los manejadores de estado al usuario del lado servidor, y no tratarlos nunca como autenticación.&lt;/li>
&lt;li>Decidir entre validación local e introspección sabiendo que la primera retrasa la revocación hasta la expiración, y acortar la vida del token en consecuencia.&lt;/li>
&lt;li>Fijar hash de descripción y esquema de entrada por fuera, porque no lo cubre la especificación.&lt;/li>
&lt;li>Poner un motor de políticas para la autorización por herramienta, y no intentar modelarla con roles.&lt;/li>
&lt;li>Si se plantea la extensión empresarial, comprobar antes quién emite la concesión: Keycloak hoy solo sabe recibirla, en experimental y contra un borrador anterior.&lt;/li>
&lt;/ol>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>El SDK de Python monta el documento de metadatos solo, y por eso parece resuelto.&lt;/strong> Lo que publica lleva un solo servidor de autorización y sin nombre ni documentación.&lt;/p>
&lt;p>&lt;strong>El aviso de obsolescencia de la validación de audiencia no activa nada.&lt;/strong> Avisa y sigue comportándose como desactivado.&lt;/p>
&lt;p>&lt;strong>El SDK de TypeScript no valida la audiencia en absoluto.&lt;/strong> No es configuración, es que no existe código para ello.&lt;/p>
&lt;p>&lt;strong>El reto de Python no lleva el ámbito&lt;/strong>, así que un cliente que reciba un 403 no sabe qué pedir.&lt;/p>
&lt;p>&lt;strong>El rodeo de los ámbitos permite dos audiencias en el mismo token.&lt;/strong> Cada mapeador aporta la suya, y no hay política de cliente que lo limite. La defensa está en el recurso.&lt;/p>
&lt;p>&lt;strong>La audiencia del intercambio de token es un identificador de cliente, no una URL.&lt;/strong> Si el servidor MCP no está dado de alta con su URL por identificador, la audiencia resultante no coincidirá con lo que el recurso valida.&lt;/p>
&lt;p>&lt;strong>El intercambio de token exige que el gateway esté en la audiencia del token de usuario.&lt;/strong> Es la condición que más tiempo hace perder la primera vez.&lt;/p>
&lt;p>&lt;strong>El registro dinámico de clientes está deprecado&lt;/strong> desde esta revisión, y el sustituto, los documentos de metadatos de identificador de cliente, es experimental en Keycloak y tiene un fallo abierto con documentos que traen campos desconocidos, lo que bloquea el inicio de sesión con clientes reales.&lt;/p>
&lt;p>&lt;strong>La concesión de aserción de identidad de LiteLLM depende del proveedor OIDC genérico.&lt;/strong> Con Google, Microsoft o SAML no se captura ninguna aserción y fallan todos los usuarios.&lt;/p>
&lt;p>&lt;strong>La elevación por pasos ya no es responsabilidad del servidor.&lt;/strong> La acumulación de ámbitos pasó al cliente en esta revisión.&lt;/p>
&lt;p>&lt;strong>La señal de elevación de autenticación va en un 401, no en un 403.&lt;/strong> El ámbito insuficiente es 403; pedir un nivel de autenticación mayor es otra cosa y otro código.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>El resumen de este artículo cabe en una frase: el proveedor de identidad es la mitad barata del problema, y la mitad cara es el recurso protegido, que ningún SDK da hecho y que en los dos oficiales viene con la validación de audiencia desactivada o ausente.&lt;/p>
&lt;p>De ahí sale un orden de trabajo. Primero el verificador de tokens, con audiencia validada en los dos sentidos, porque sin él lo demás es decoración. Segundo, el documento de metadatos completo, montado a mano. Tercero, sustituir cualquier paso de token por intercambio, con el servidor MCP dado de alta con su URL por identificador. Cuarto, un motor de políticas para lo que los roles no modelan.&lt;/p>
&lt;p>Y una decisión de calendario que conviene tomar con la información delante. La extensión de autorización gestionada por la empresa es el camino correcto para una organización con proveedor propio, está estable y tiene servidores en producción detrás. Lo que no está listo es Keycloak como emisor. Quien quiera ese flujo hoy tiene dos opciones honestas: delegar la mecánica en un gateway que la implemente, o esperar. Ponerlo en producción con una función que la documentación oficial marca como experimental y desaconseja expresamente no es una de ellas.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">Keycloak en una plataforma de IA&lt;/a>: la pieza de identidad por dentro, y el hueco que este artículo cierra.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/mcp-crece-autenticacion-keycloak/">Cuando el MCP crece: ponerle autenticación con Keycloak&lt;/a>: el montaje básico, anterior a esta revisión de la especificación.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">El gateway MCP de LiteLLM&lt;/a>: la segunda puerta de entrada, sus permisos, su coste y su falta de registro de auditoría.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/">El gateway no vive solo&lt;/a>: la validación de audiencia que tampoco ocurre del lado del gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/identidad-aislamiento-spiffe-confidential-containers/">Cadena de confianza del modelo (4 de 4)&lt;/a>: identidad de carga con atestación, el otro plano.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/mcp-observability-otel/">MCP por dentro y su observabilidad&lt;/a>: el protocolo y sus primitivas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/">El contratista con la llave maestra: aislar agentes de IA&lt;/a>: el aislamiento de red que todo lo anterior presupone.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Especificación MCP, revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">autorización&lt;/a>, &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration">registro de clientes&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization">Extensión de autorización gestionada por la empresa&lt;/a> y su &lt;a href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx">especificación estable&lt;/a>, del SEP-990. Anuncio de 18 de junio de 2026.&lt;/li>
&lt;li>&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/">draft-ietf-oauth-identity-assertion-authz-grant&lt;/a>, revisión 04 de 21 de mayo de 2026, grupo de trabajo OAuth del IETF.&lt;/li>
&lt;li>Código de los SDK oficiales de MCP: &lt;code>python-sdk&lt;/code> y &lt;code>typescript-sdk&lt;/code> 2.0.0-alfa, clonados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración de Keycloak con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/securing-apps/identity-assertion-jwt-authorization-grant">concesión de autorización por aserción de identidad&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Incidencias de Keycloak &lt;a href="https://github.com/keycloak/keycloak/issues/14355">14355&lt;/a>, &lt;a href="https://github.com/keycloak/keycloak/issues/47117">47117&lt;/a> y &lt;a href="https://github.com/keycloak/keycloak/issues/51039">51039&lt;/a>, y petición de cambios &lt;a href="https://github.com/keycloak/keycloak/pull/35711">35711&lt;/a>.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">NSA y SEI, Model Context Protocol: Security Design Considerations&lt;/a>, mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://openfga.dev/docs/use-cases/mcp-server-authorization">OpenFGA, autorización de servidores MCP&lt;/a>, actualizado el 9 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://invariantlabs-ai.github.io/docs/mcp-scan/">mcp-scan de Invariant Labs&lt;/a>, Apache 2.0.&lt;/li>
&lt;li>Documentación de &lt;a href="https://docs.solo.io/agentgateway/2.3.x/mcp/auth/setup/">agentgateway&lt;/a>, &lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/mcp">Traefik Hub&lt;/a>, &lt;a href="https://developer.konghq.com/plugins/ai-mcp-oauth2/">Kong&lt;/a> y &lt;a href="https://www.pomerium.com/docs/capabilities/mcp">Pomerium&lt;/a>, consultadas el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Keycloak en una plataforma de IA: dónde encaja, qué resuelve y los dos estándares que MCP exige y no implementa</title><link>https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/</link><pubDate>Sat, 12 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/</guid><description>&lt;blockquote>
&lt;p>Este artículo abre el bloque de identidad del track operativo. &lt;a href="https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/">El anterior&lt;/a> trató el lado del gateway: el JWT que no valida audiencia, el usuario que no llega a la traza. Este trata la pieza que hay al otro lado. Verificado contra Keycloak 26.7.3, la revisión 2026-07-28 de la especificación de MCP y el texto del Real Decreto 311/2022 en el BOE.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Keycloak 26.7.3 se publicó el 31 de agosto de 2026&lt;/strong> y sigue en incubación en la CNCF, donde entró en abril de 2023. La cadencia es de cuatro versiones menores al año y el soporte cubre en la práctica una sola rama: los veinte fallos de seguridad corregidos en la 26.7.3 no se trasladaron a las ramas 26.4, 26.5 ni 26.6 en el momento de publicarse.&lt;/p>
&lt;p>&lt;strong>La distribución sobre Quarkus separa configuración de compilación y de ejecución.&lt;/strong> Lo que se fija en la fase de compilación queda congelado en la imagen, y cambiarlo al arrancar obliga a reconstruir. En contenedores, el patrón es compilar en la imagen y arrancar con la bandera de optimizado.&lt;/p>
&lt;p>&lt;strong>El almacenamiento es relacional y solo relacional.&lt;/strong> El motor alternativo que estuvo en desarrollo se retiró en la versión 25.0. Desde la 26.0 las sesiones se persisten en base de datos por defecto, lo que cambia el perfil de carga: el coste dominante en los bancos de pruebas oficiales es la CPU de la base de datos.&lt;/p>
&lt;p>&lt;strong>Organizaciones resuelve el multi-inquilino dentro de un realm&lt;/strong>, con soporte pleno desde la 26.0. Agrupa miembros, asocia dominios de correo y vincula proveedores de identidad por organización. Es la alternativa a replicar realms por cliente.&lt;/p>
&lt;p>&lt;strong>El cuello de botella medido es el hash de contraseña.&lt;/strong> Desde la 25.0 el algoritmo por defecto fuera de entornos FIPS es Argon2, con unos 7 MB de memoria por operación. La guía oficial de dimensionado da un vCPU por cada 15 inicios de sesión con contraseña por segundo, frente a 120 por segundo para credenciales de cliente y para refrescos.&lt;/p>
&lt;p>&lt;strong>Keycloak no implementa dos estándares que la especificación de MCP marca como obligatorios.&lt;/strong> Los metadatos de recurso protegido corresponden al servidor MCP, no al servidor de autorización, así que ahí no hay nada que reprochar. Los indicadores de recurso sí le corresponden y &lt;strong>no están soportados&lt;/strong>. La propia documentación de Keycloak declara conformidad parcial con las tres últimas revisiones de MCP por ese motivo. El sustituto práctico son los ámbitos y un mapeador de audiencia.&lt;/p>
&lt;p>&lt;strong>Hay un fallo crítico reciente que conviene mirar hoy mismo.&lt;/strong> &lt;code>CVE-2026-18963&lt;/code>, con puntuación 9,1, permitía restablecer la contraseña de cualquier usuario sin autenticarse, administradores incluidos. Corregido en 26.7.2, publicada el 19 de agosto de 2026.&lt;/p>
&lt;h2 id="estás-aquí-la-capa-transversal">Estás aquí: la capa transversal&lt;/h2>
&lt;p>La identidad no es una etapa del pipeline, es una capa que las cruza todas. Aparece en el &lt;a href="https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/">hardening del stack&lt;/a> como una de las siete capas de defensa, en &lt;a href="https://blog.lo0.es/posts/identidad-aislamiento-spiffe-confidential-containers/">la cadena de confianza del modelo&lt;/a> como identidad de carga, en &lt;a href="https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/">el gateway&lt;/a> como validación de token, y en &lt;a href="https://blog.lo0.es/posts/mcp-crece-autenticacion-keycloak/">el artículo sobre autenticar servidores MCP&lt;/a> como servidor de autorización.&lt;/p>
&lt;p>Lo que faltaba era el artículo de la pieza en sí, y sobre todo la decisión de dónde ponerla y qué esperar de ella.&lt;/p>
&lt;h2 id="la-analogía-el-registro-civil-y-los-permisos-de-obra">La analogía: el registro civil y los permisos de obra&lt;/h2>
&lt;p>Un ayuntamiento pequeño tiene un padrón, que dice quién vive allí, y tiene licencias, que dicen quién puede hacer qué. Son dos cosas distintas y conviene no confundirlas, porque el padrón vale para toda la ciudad mientras que una licencia vale para una obra concreta en una calle concreta.&lt;/p>
&lt;p>Keycloak es el padrón. Sabe quién es cada persona, la identifica de forma fiable, sabe a qué colectivos pertenece y emite un documento acreditativo que caduca. Lo que no hace es decidir si esta persona puede modificar este fichero concreto: eso es la licencia, y vive en cada servicio.&lt;/p>
&lt;p>La confusión entre las dos cosas produce el error de diseño más caro en plataformas de este tipo, que consiste en meter en el token todo lo que hará falta decidir después. El documento del padrón crece, se reparte por todas partes, y cuando cambia un permiso hay que esperar a que caduque el documento de todo el mundo.&lt;/p>
&lt;p>Hay un tercer elemento que la analogía recoge bien. Un documento acreditativo emitido para presentar en Hacienda no debería servir para entrar en el depósito municipal. Eso es la audiencia del token, y es el asunto central de la segunda mitad del artículo.&lt;/p>
&lt;h2 id="parte-1-qué-es-keycloak-hoy">Parte 1. Qué es Keycloak hoy&lt;/h2>
&lt;h3 id="estado-del-proyecto">Estado del proyecto&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dato&lt;/th>
&lt;th>Valor&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Última versión estable&lt;/td>
&lt;td>26.7.3, publicada el 31 de agosto de 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Anteriores&lt;/td>
&lt;td>26.7.2 (19 de agosto), 26.7.0 (9 de julio), 26.6.0 (8 de abril)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cadencia&lt;/td>
&lt;td>cuatro versiones menores al año&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNCF&lt;/td>
&lt;td>incubación desde el 10 de abril de 2023, sin graduar&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Soporte&lt;/td>
&lt;td>la rama actual; las anteriores no reciben correcciones de forma fiable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Ese último punto merece subrayarse porque condiciona la política de actualización. El análisis de la 26.7.3 muestra que sus veinte correcciones de seguridad no se trasladaron a las ramas anteriores al publicarse. Operar Keycloak significa ir al día de la rama vigente, no quedarse en una versión &amp;ldquo;estable&amp;rdquo; durante un año.&lt;/p>
&lt;p>Desde la 26.0 hay garantía de compatibilidad hacia atrás en versiones menores para las funciones marcadas como plenamente soportadas, y los cambios que rompen son opcionales.&lt;/p>
&lt;h3 id="la-arquitectura-interna-en-cinco-piezas">La arquitectura interna en cinco piezas&lt;/h3>
&lt;p>&lt;strong>Dos modos de arranque.&lt;/strong> El de desarrollo habilita HTTP, no resuelve el nombre de host de forma estricta y desactiva la caché de temas. El de producción exige nombre de host y TLS, o no arranca. Confundirlos en producción es el primer antipatrón de la lista final.&lt;/p>
&lt;p>&lt;strong>La fase de compilación.&lt;/strong> El comando de construcción materializa las optimizaciones en la imagen. Las opciones de compilación, como el motor de base de datos, las funciones habilitadas o las métricas, quedan congeladas. Las de ejecución, como la contraseña de la base de datos o el nombre de host, se aplican al arrancar. Cambiar una opción de compilación al arrancar fuerza una reconstrucción con el coste de latencia correspondiente. El patrón correcto en contenedores es construir en la imagen y arrancar con la bandera de optimizado.&lt;/p>
&lt;p>&lt;strong>Extensiones por SPI.&lt;/strong> Autenticadores, mapeadores de protocolo, almacenamiento de usuarios, escuchas de eventos, algoritmos de hash y bóvedas de secretos. Se empaquetan como JAR y se resuelven en la fase de compilación.&lt;/p>
&lt;p>&lt;strong>Caché Infinispan.&lt;/strong> Embebida por defecto, con descubrimiento por base de datos como mecanismo recomendado. Las alternativas de descubrimiento por DNS de Kubernetes, TCP y UDP están obsoletas. Hay cachés locales, con capacidades por defecto de 10.000 entradas en varias de ellas, y cachés replicadas para sesiones, sesiones de cliente, sesiones sin conexión, sesiones de autenticación, fallos de inicio de sesión y tokens de acción. El modo remoto, con un Infinispan externo, hace falta para el despliegue multi-sitio de primera generación.&lt;/p>
&lt;p>&lt;strong>Almacenamiento relacional.&lt;/strong> JPA sobre base de datos. El motor alternativo se eliminó con la versión 25.0. Las bases soportadas hoy incluyen PostgreSQL de la 14 a la 18, MariaDB y MySQL en sus versiones de soporte extendido, SQL Server 2019 y 2022, Oracle 19c y 23.x, y las variantes gestionadas de Aurora PostgreSQL y Azure SQL.&lt;/p>
&lt;h3 id="el-modelo-conceptual-mínimo">El modelo conceptual mínimo&lt;/h3>
&lt;p>Antes de tocar nada hay que tener claros ocho conceptos, y el orden en que se relacionan:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Realm&lt;/strong>: la frontera de aislamiento. Usuarios propios, claves de firma propias, políticas propias. Dos realms no comparten nada.&lt;/li>
&lt;li>&lt;strong>Cliente&lt;/strong>: una aplicación que pide tokens. Público si no puede guardar un secreto, como una aplicación de navegador; confidencial si sí puede, como un servicio; y de solo portador si únicamente valida tokens sin pedirlos.&lt;/li>
&lt;li>&lt;strong>Ámbito de cliente&lt;/strong>: un paquete reutilizable de ámbitos y mapeadores, que se asigna a clientes como predeterminado o como opcional. Es la unidad con la que se acota lo que lleva un token.&lt;/li>
&lt;li>&lt;strong>Mapeador de protocolo&lt;/strong>: lo que proyecta un atributo, un rol o una audiencia dentro del token. El mapeador de audiencia es el que resuelve el problema central de este artículo.&lt;/li>
&lt;li>&lt;strong>Rol&lt;/strong>, de realm o de cliente, y &lt;strong>rol compuesto&lt;/strong>, que agrega otros.&lt;/li>
&lt;li>&lt;strong>Grupo&lt;/strong>, jerárquico, con roles y atributos heredados.&lt;/li>
&lt;li>&lt;strong>Proveedor de identidad&lt;/strong>: federación hacia otro OIDC o SAML externo. &lt;strong>Federación de usuarios&lt;/strong>: LDAP, Active Directory y Kerberos, con sincronización y mapeo de atributos.&lt;/li>
&lt;li>&lt;strong>Flujo de autenticación&lt;/strong>: una secuencia de ejecuciones con requisitos de obligatorio, alternativo, condicional o desactivado, y subflujos. Es donde se monta un segundo factor o una comprobación propia.&lt;/li>
&lt;/ul>
&lt;p>Y una función que cambia arquitecturas: &lt;strong>organizaciones&lt;/strong>, plenamente soportada desde la 26.0. Resuelve el multi-inquilino dentro de un mismo realm, agrupando miembros, asociando dominios de correo y vinculando proveedores de identidad por organización, de forma que el descubrimiento del proveedor se hace por el dominio del correo del usuario. La alternativa clásica, un realm por cliente, multiplica la administración y no comparte configuración.&lt;/p>
&lt;h2 id="parte-2-quién-puede-autenticarse-contra-él">Parte 2. Quién puede autenticarse contra él&lt;/h2>
&lt;p>Esta es la tabla que hay que tener antes de dibujar nada. La columna de la derecha es la que suele decidir.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Pieza&lt;/th>
&lt;th>Protocolo&lt;/th>
&lt;th>Coste&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>API de Kubernetes&lt;/td>
&lt;td>OIDC nativo, o el fichero de configuración estructurada de autenticación&lt;/td>
&lt;td>Libre&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>OIDC y JWT, más SSO de la interfaz&lt;/td>
&lt;td>&lt;strong>JWT y SSO por encima de cinco usuarios: Enterprise&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>solo clave estática&lt;/td>
&lt;td>No hay OIDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>OIDC genérico y proveedor Keycloak&lt;/td>
&lt;td>SSO libre; control de acceso por proyecto: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grafana&lt;/td>
&lt;td>OAuth genérico &lt;strong>sí en la edición abierta&lt;/strong>&lt;/td>
&lt;td>Sincronización de equipos: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backstage&lt;/td>
&lt;td>OIDC genérico y proveedor Keycloak mantenido por la comunidad&lt;/td>
&lt;td>Libre&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open WebUI&lt;/td>
&lt;td>OIDC, con gestión de roles por claim&lt;/td>
&lt;td>Libre&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LibreChat&lt;/td>
&lt;td>OIDC genérico, con Keycloak documentado&lt;/td>
&lt;td>Libre&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Argo CD&lt;/td>
&lt;td>OIDC nativo o Dex, con grupos hacia su fichero de RBAC&lt;/td>
&lt;td>Libre&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MinIO&lt;/td>
&lt;td>OIDC con asunción de rol mediante identidad web&lt;/td>
&lt;td>Ver la nota de abajo&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dos precisiones que cambian decisiones.&lt;/p>
&lt;p>&lt;strong>Kubernetes.&lt;/strong> El mecanismo moderno es la configuración estructurada de autenticación, que permite varios emisores JWT y validaciones y mapeos con expresiones CEL. La propuesta correspondiente, KEP-3331, pasó a alfa en la 1.29, a beta en la 1.30 y es &lt;strong>estable desde la 1.34&lt;/strong>. Es la vía a usar hoy, y sustituye a las banderas sueltas del API server.&lt;/p>
&lt;p>&lt;strong>vLLM no tiene identidad.&lt;/strong> Lo único que hay es una clave estática por variable de entorno. Esto no es un defecto a corregir, es un hecho de arquitectura: el motor va detrás del gateway y su control de acceso es de red, no de identidad. Cualquier diseño que exponga vLLM directamente a usuarios está renunciando a la trazabilidad por persona.&lt;/p>
&lt;p>Sobre MinIO conviene una advertencia de honestidad: la documentación pública redirige hoy a la edición comercial, así que el detalle de cómo mapea políticas desde un claim está verificado ahí y no en la edición abierta. Quien dependa de ese mecanismo debería confirmarlo contra su propia versión antes de diseñar sobre él.&lt;/p>
&lt;h2 id="parte-3-humanos-y-máquinas">Parte 3. Humanos y máquinas&lt;/h2>
&lt;p>Son dos problemas distintos y conviene resolverlos por separado.&lt;/p>
&lt;h3 id="personas">Personas&lt;/h3>
&lt;p>Código de autorización con PKCE, con método S256. La especificación de MCP lo exige cuando el cliente sea técnicamente capaz, y va más allá: obliga a &lt;strong>rechazar el flujo&lt;/strong> si los metadatos del servidor de autorización no publican los métodos de desafío soportados.&lt;/p>
&lt;h3 id="cargas-sin-persona-detrás">Cargas sin persona detrás&lt;/h3>
&lt;p>Aquí la opción obvia son las credenciales de cliente con una cuenta de servicio de Keycloak, autenticada con secreto o, mejor, con JWT firmado por clave privada.&lt;/p>
&lt;p>Pero hay una opción menos conocida y mejor para cargas dentro del cluster: la &lt;strong>autenticación federada de clientes&lt;/strong>, presentada en enero de 2026 con el lema de acabar con los secretos. Permite que un cliente se autentique con un token emitido por un proveedor externo, y uno de esos proveedores es Kubernetes. El anuncio situaba el soporte pleno de la variante de cuentas de servicio de Kubernetes y de OIDC en la versión 26.6; la variante SPIFFE, en cambio, dice expresamente que seguirá en vista previa hasta que se cierre el estándar de autenticación de cliente SPIFFE en el IETF.&lt;/p>
&lt;p>Eso significa que un pod puede autenticarse contra Keycloak con el token que el kubelet ya le proyecta, sin ningún secreto adicional que rotar. Conviene confirmar el estado exacto y el nombre de la bandera contra las notas de la versión que se tenga desplegada, porque la función es reciente y ha cambiado de estado entre la 26.5 y la 26.6. Es preferible al intercambio de tokens, cuya versión estándar está soportada pero es solo interno hacia interno, mientras que la antigua, la que cubría externo hacia interno y la suplantación, está obsoleta y en vista previa.&lt;/p>
&lt;h3 id="keycloak-frente-a-spiffe">Keycloak frente a SPIFFE&lt;/h3>
&lt;p>La pregunta aparece siempre y la respuesta es que resuelven cosas distintas con un solapamiento pequeño.&lt;/p>
&lt;p>SPIRE atesta el nodo y el proceso, y emite documentos de identidad de vida corta sin ningún secreto previo. Keycloak no atesta cargas: confía en una credencial que alguien tuvo que colocar, salvo en el caso de la autenticación federada que se acaba de mencionar, donde quien atesta es Kubernetes. A cambio, Keycloak federa personas, emite tokens con datos de negocio y gobierna el consentimiento, cosas que SPIFFE no hace.&lt;/p>
&lt;p>La regla práctica: SPIFFE para identidad entre servicios dentro del perímetro, Keycloak para todo lo que tenga una persona detrás o cruce el perímetro. El detalle de SPIFFE está en &lt;a href="https://blog.lo0.es/posts/identidad-aislamiento-spiffe-confidential-containers/">el cierre de la serie de la cadena de confianza&lt;/a>.&lt;/p>
&lt;h2 id="parte-4-mcp-y-los-dos-estándares-que-faltan">Parte 4. MCP, y los dos estándares que faltan&lt;/h2>
&lt;p>Esta parte es la que más consecuencias tiene y la que peor documentada está fuera de las fuentes primarias.&lt;/p>
&lt;h3 id="lo-que-exige-la-especificación">Lo que exige la especificación&lt;/h3>
&lt;p>De la revisión 2026-07-28, con las palabras normativas tal como aparecen:&lt;/p>
&lt;ul>
&lt;li>El servidor MCP &lt;strong>debe&lt;/strong> implementar los metadatos de recurso protegido del RFC 9728, y el cliente &lt;strong>debe&lt;/strong> usarlos para descubrir el servidor de autorización.&lt;/li>
&lt;li>El cliente &lt;strong>debe&lt;/strong> implementar los indicadores de recurso del RFC 8707, incluyendo el parámetro &lt;code>resource&lt;/code> &lt;strong>tanto en la petición de autorización como en la de token&lt;/strong>, y &lt;strong>debe&lt;/strong> mandarlo con independencia de que el servidor de autorización lo soporte.&lt;/li>
&lt;li>El servidor MCP &lt;strong>debe&lt;/strong> validar que el token de acceso se emitió específicamente para él como audiencia, y &lt;strong>no debe&lt;/strong> aceptar ni retransmitir ningún otro token.&lt;/li>
&lt;li>Los servidores de autorización y los clientes &lt;strong>deberían&lt;/strong> soportar documentos de metadatos de identificador de cliente. El registro dinámico del RFC 7591 pasa a ser opcional y queda declarado obsoleto, retenido por compatibilidad.&lt;/li>
&lt;/ul>
&lt;p>La revisión añade además el emisor en la respuesta de autorización, del RFC 9207, y un modelo sin estado con manejadores de estado donde se dice expresamente que poseer un manejador de estado no equivale a estar autenticado.&lt;/p>
&lt;h3 id="lo-que-soporta-keycloak">Lo que soporta Keycloak&lt;/h3>
&lt;p>De su propia guía de integración con MCP:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Estándar&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 y metadatos de servidor de autorización&lt;/td>
&lt;td>Soportado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 9207, emisor en la respuesta&lt;/td>
&lt;td>Soportado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 7591, registro dinámico&lt;/td>
&lt;td>Soportado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Documento de metadatos de identificador de cliente&lt;/td>
&lt;td>Soportado, &lt;strong>experimental&lt;/strong>, tras activar la función&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 8707, indicadores de recurso&lt;/strong>&lt;/td>
&lt;td>&lt;strong>No soportado&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 9728, metadatos de recurso protegido&lt;/strong>&lt;/td>
&lt;td>Fuera de alcance, es del servidor MCP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La conformidad declarada por el propio proyecto es &amp;ldquo;parcialmente soportado, sin indicadores de recurso&amp;rdquo; para las tres últimas revisiones de MCP.&lt;/p>
&lt;p>Dos conclusiones prácticas:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>El documento de metadatos de recurso protegido lo publica el servidor MCP o un proxy delante de él&lt;/strong>, nunca Keycloak. Es correcto y no hay nada que arreglar, pero hay que acordarse de implementarlo, porque el cliente lo necesita para descubrir a dónde ir.&lt;/li>
&lt;li>&lt;strong>Sin indicadores de recurso, la audiencia se acota con ámbitos.&lt;/strong> El camino oficial es usar el parámetro &lt;code>scope&lt;/code> en lugar de &lt;code>resource&lt;/code>, y colgar de ese ámbito de cliente un mapeador de audiencia con la URL del servidor MCP como audiencia personalizada incluida.&lt;/li>
&lt;/ol>
&lt;p>Es un rodeo, funciona, y hay que documentarlo en el diseño porque cualquiera que venga detrás buscará el parámetro estándar y no lo encontrará.&lt;/p>
&lt;h2 id="parte-5-el-delegado-confuso">Parte 5. El delegado confuso&lt;/h2>
&lt;p>Con lo anterior encima de la mesa, el error clásico se ve solo. Un token con audiencia genérica convierte cualquier servicio comprometido en una llave maestra: quien obtiene el token que un usuario presentó al panel puede presentarlo al gateway, y de ahí al servidor MCP.&lt;/p>
&lt;p>La especificación de MCP es explícita en tres frases que conviene citar tal cual en cualquier documento de arquitectura:&lt;/p>
&lt;ul>
&lt;li>Los servidores MCP &lt;strong>no deben&lt;/strong> aceptar tokens que no se emitieran explícitamente para ellos.&lt;/li>
&lt;li>Si el servidor MCP llama a APIs de más arriba, &lt;strong>no debe&lt;/strong> reenviar el token que recibió del cliente.&lt;/li>
&lt;li>Los proxies MCP que usen identificadores de cliente estáticos &lt;strong>deben&lt;/strong> obtener consentimiento del usuario por cada cliente registrado dinámicamente antes de reenviar a servidores de autorización de terceros.&lt;/li>
&lt;/ul>
&lt;p>Traducido a la figura concreta que se viene tratando en este track: &lt;strong>el gateway no debe reenviar el JWT del usuario a servidores MCP de terceros&lt;/strong>. Tiene que obtener un token distinto con la audiencia correcta, o actuar como cliente OAuth propio. Por defecto, como ya se documentó &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">en el artículo del gateway MCP&lt;/a>, el comportamiento es usar la credencial propia del servidor, que es el correcto; hay dos modos que reenvían el token del llamante y ambos tienen nombre en la especificación.&lt;/p>
&lt;h2 id="parte-6-dos-horas-de-tarea-contra-cinco-minutos-de-token">Parte 6. Dos horas de tarea contra cinco minutos de token&lt;/h2>
&lt;p>La especificación empuja hacia tokens de vida corta y rotación obligatoria del token de refresco en clientes públicos. Una tarea agéntica de dos horas excede con holgura la vida de un token de acceso y puede exceder también la inactividad de la sesión si nadie refresca.&lt;/p>
&lt;p>Tres opciones reales, con su coste:&lt;/p>
&lt;p>&lt;strong>Tokens sin conexión.&lt;/strong> Sobreviven a la expiración de la sesión, y caducan por inactividad propia o por un máximo si se activa esa limitación. El riesgo es evidente: una credencial casi permanente, difícil de inventariar. Y la especificación de MCP añade que los servidores MCP &lt;strong>no deberían&lt;/strong> incluir ese ámbito ni en la cabecera de autenticación ni en los ámbitos soportados de sus metadatos.&lt;/p>
&lt;p>&lt;strong>Refresco automático en el cliente.&lt;/strong> Es la respuesta correcta cuando hay cliente que la pueda implementar, con rotación del token de refresco y almacenamiento seguro. Para un agente sin persona detrás, es mejor renovar por credenciales de cliente.&lt;/p>
&lt;p>&lt;strong>Claves virtuales de larga vida en el gateway.&lt;/strong> Funciona, y es lo que la mayoría acaba haciendo. El precio hay que escribirlo en el análisis de riesgos: la identidad queda desacoplada del proveedor, de modo que dar de baja a alguien en Keycloak no invalida su clave, y la trazabilidad hacia la persona real depende de la disciplina con que se hayan creado las claves. Las palancas que compensan son la caducidad, la rotación automática y el alias obligatorio, tratadas &lt;a href="https://blog.lo0.es/posts/litellm-limites-presupuestos-claves-virtuales/">en el artículo de las claves virtuales&lt;/a>.&lt;/p>
&lt;p>Una nota de honestidad sobre los valores por defecto. Los números que circulan (cinco minutos de token de acceso, treinta de inactividad de sesión, diez horas de máximo) no aparecen publicados en la guía de administración ni los localicé en el código. Trátense como observación de consola y confírmense en el realm propio antes de citarlos en un documento.&lt;/p>
&lt;h2 id="parte-7-dimensionar-y-operar">Parte 7. Dimensionar y operar&lt;/h2>
&lt;h3 id="en-kubernetes">En Kubernetes&lt;/h3>
&lt;p>El operador expone tres recursos personalizados: el servidor, la importación de realm y el cliente OIDC. Las sondas y las métricas viven en el &lt;strong>puerto de gestión 9000&lt;/strong>, con las rutas de salud y preparación, y las métricas en formato Prometheus siempre que se haya habilitado la opción correspondiente en la fase de compilación. Ese puerto no se expone públicamente.&lt;/p>
&lt;p>Para alta disponibilidad hay tres figuras. Un solo cluster repartido entre zonas, sin infraestructura adicional. Multi-cluster de primera generación, con balanceador externo e Infinispan externo en cada sitio. Y multi-cluster de segunda generación, introducido en la 26.7.0 y todavía en vista previa, que conecta clusters &lt;strong>sin Infinispan externo&lt;/strong>, usando la base replicada de forma síncrona como fuente de verdad, con menos caché y más carga de base de datos.&lt;/p>
&lt;h3 id="las-cifras-oficiales-de-dimensionado">Las cifras oficiales de dimensionado&lt;/h3>
&lt;p>De la guía de conceptos de dimensionado de CPU y memoria, que sigue publicada:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>1 vCPU por cada 15 inicios de sesión con contraseña por segundo&lt;/strong>, probado hasta 300 por segundo.&lt;/li>
&lt;li>&lt;strong>1 vCPU por cada 120 concesiones de credenciales de cliente por segundo&lt;/strong>, probado hasta 2.000.&lt;/li>
&lt;li>&lt;strong>1 vCPU por cada 120 peticiones de refresco de token por segundo&lt;/strong>, probado hasta 435.&lt;/li>
&lt;li>Dejar un &lt;strong>150 % de margen&lt;/strong> de CPU para picos, arranque y conmutación.&lt;/li>
&lt;li>&lt;strong>1.250 MB de memoria por pod&lt;/strong> con datos de realm y 10.000 sesiones en caché. Keycloak asigna el 70 % del límite al heap y unos 300 MB fuera de él, así que el límite sale de restar esos 300 MB al uso esperado y dividir entre 0,7.&lt;/li>
&lt;/ul>
&lt;p>Los supuestos declarados de esas cifras incluyen Argon2 con cinco iteraciones, sesiones en base de datos y la caché por defecto de 10.000 entradas.&lt;/p>
&lt;p>La diferencia entre 15 y 120 es toda la historia del rendimiento de Keycloak: &lt;strong>el coste está en el hash de contraseña&lt;/strong>. Desde la 25.0, el algoritmo por defecto fuera de entornos FIPS es Argon2, con unos 7 MB de memoria por operación. Antes era PBKDF2, cuyas iteraciones se multiplicaron por diez en la versión 24. En modo FIPS se sigue usando PBKDF2.&lt;/p>
&lt;p>De ahí sale una consecuencia de arquitectura para una plataforma de IA: &lt;strong>las cargas de máquina, que van por credenciales de cliente, cuestan ocho veces menos que un inicio de sesión humano&lt;/strong>. Dimensionar por número de servicios es barato; dimensionar por picos de inicio de sesión por la mañana es lo caro.&lt;/p>
&lt;h3 id="el-banco-de-pruebas">El banco de pruebas&lt;/h3>
&lt;p>Del informe de rendimiento de la 26.4, publicado el 1 de octubre de 2025: hasta 2.000 inicios de sesión por segundo y 10.000 refrescos por segundo con tres pods, 74 vCPU en total y 8 GB por pod, sobre una base Aurora PostgreSQL, con escalado vertical casi lineal.&lt;/p>
&lt;p>Dos hallazgos útiles de ese informe. Con 20 ms de tiempo de ida y vuelta entre sitios, el percentil 99 sube de 51 a 130 ms, y el proyecto &lt;strong>desaconseja desplegar entre regiones distintas&lt;/strong>. Y subir la caché de 10.000 a 200.000 entradas bajó el pico de CPU de la base de datos del 77,77 % al 63,77 %, a cambio de pausas de recolección de basura algo mayores.&lt;/p>
&lt;h3 id="seguridad">Seguridad&lt;/h3>
&lt;p>El asunto urgente, si el despliegue está por debajo de la 26.7.2:&lt;/p>
&lt;p>&lt;strong>&lt;code>CVE-2026-18963&lt;/code>, puntuación 9,1, crítico.&lt;/strong> Un salto del token de acción por correo en el flujo de restablecimiento de credenciales permitía restablecer la contraseña de cualquier usuario sin autenticación previa, administradores incluidos. Corregido en 26.7.2, publicada el 19 de agosto de 2026. La mitigación temporal, si no se puede actualizar, es desactivar la recuperación de contraseña en todos los realms. No consta explotación conocida a finales de agosto.&lt;/p>
&lt;p>En la 26.7.3 hay otros diecinueve, entre ellos la falta de verificación del nombre de host en el certificado TLS hacia LDAP con puntuación 8,8, dos saltos de restricción de inquilino en el intercambio de tokens con corredores de Microsoft y Google, y dos saltos de control de acceso en organizaciones.&lt;/p>
&lt;p>El endurecimiento oficial se resume en cuatro decisiones:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Nombre de host estricto&lt;/strong>, que es el valor por defecto desde la segunda generación de esa configuración. El servidor no deduce la URL de las cabeceras, lo que evita envenenar los enlaces de los correos y las redirecciones.&lt;/li>
&lt;li>&lt;strong>Consola de administración en un nombre de host distinto&lt;/strong>, y restringida en red.&lt;/li>
&lt;li>&lt;strong>Cabeceras de proxy declaradas explícitamente&lt;/strong>, confiando solo en un proxy inverso controlado.&lt;/li>
&lt;li>&lt;strong>Puerto de gestión separado y no expuesto.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="parte-8-el-mapeo-al-ens">Parte 8. El mapeo al ENS&lt;/h2>
&lt;p>Los códigos y títulos siguientes están verificados contra el texto oficial del Real Decreto 311/2022 publicado en el BOE, Anexo II.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Código&lt;/th>
&lt;th>Título literal&lt;/th>
&lt;th>Qué aporta un IdP central&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>op.acc.1&lt;/td>
&lt;td>Identificación&lt;/td>
&lt;td>Un identificador singular por entidad, usuario o proceso que accede&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.2&lt;/td>
&lt;td>Requisitos de acceso&lt;/td>
&lt;td>Los permisos se conceden por rol y grupo, no por credencial suelta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.3&lt;/td>
&lt;td>Segregación de funciones y tareas&lt;/td>
&lt;td>Lo soporta con roles, pero el control es organizativo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.4&lt;/td>
&lt;td>Proceso de gestión de derechos de acceso&lt;/td>
&lt;td>Mínimo privilegio y política específica de acceso remoto&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.5&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios externos)&lt;/td>
&lt;td>Flujos y factores configurables por flujo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.6&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios de la organización)&lt;/td>
&lt;td>Refuerzos exigidos ya en nivel bajo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.8&lt;/td>
&lt;td>Registro de la actividad&lt;/td>
&lt;td>Aporta una parte de los eventos, no todos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.10&lt;/td>
&lt;td>Protección de claves criptográficas&lt;/td>
&lt;td>Gestión y rotación de las claves de firma del realm&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Dos matices que un auditor va a buscar.&lt;/p>
&lt;p>El primero, sobre &lt;strong>op.acc.1&lt;/strong>: el texto exige que cada entidad que accede al sistema, sea usuario o proceso, tenga un identificador singular. Eso incluye a los agentes. Una clave virtual compartida entre varios agentes no cumple, y es exactamente lo que suele pasar cuando la autenticación por JWT queda fuera del presupuesto.&lt;/p>
&lt;p>El segundo, sobre &lt;strong>op.exp.8&lt;/strong>: el control exige registrar el identificador del usuario o entidad asociado al evento, la fecha y hora, sobre qué información se realiza, el tipo y el resultado. Keycloak aporta los eventos de autenticación. Los eventos de uso los aportan el gateway y los servidores MCP, y ahí vuelven a aparecer los dos huecos ya documentados: &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">las altas y bajas de servidores MCP no generan registro de auditoría&lt;/a> y &lt;a href="https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/">el usuario de la traza lo pone el cliente&lt;/a>.&lt;/p>
&lt;p>La correspondencia entre control y producto de esta tabla es interpretación propia y debe validarla la entidad de certificación. Los códigos y títulos, no: esos son literales del BOE.&lt;/p>
&lt;h2 id="parte-9-la-configuración-que-hay-que-dejar-escrita">Parte 9. La configuración que hay que dejar escrita&lt;/h2>
&lt;p>Tres piezas concretas, para no quedarse en el nivel de los conceptos.&lt;/p>
&lt;p>&lt;strong>Arranque endurecido&lt;/strong>, como argumentos del contenedor:&lt;/p>
&lt;pre tabindex="0">&lt;code>kc.sh start --optimized \
--hostname=https://sso.ejemplo.es \
--hostname-admin=https://sso-admin.interna.ejemplo.es \
--proxy-headers=xforwarded \
--health-enabled=true \
--metrics-enabled=true \
--http-management-port=9000 \
--cache=ispn --cache-stack=jdbc-ping \
--db=postgres
&lt;/code>&lt;/pre>&lt;p>Las opciones de métricas, salud, caché y base de datos son de compilación: van en la imagen, y pasarlas aquí obliga a reconstruir si no coinciden.&lt;/p>
&lt;p>&lt;strong>El ámbito de cliente con audiencia&lt;/strong>, que es el rodeo obligado por la falta de indicadores de recurso. Creado con la herramienta de administración:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># un ámbito por recurso protegido&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp-inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># el mapeador que fija la audiencia del token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>El cliente pide ese ámbito y recibe un token cuya audiencia es solo ese servidor MCP. El servidor MCP valida la audiencia y rechaza cualquier otro token, como exige la especificación.&lt;/p>
&lt;p>&lt;strong>Importación del realm por GitOps&lt;/strong>, con el recurso personalizado del operador, para que la configuración no viva solo en la consola:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">k8s.keycloak.org/v2beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">KeycloakRealmImport&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">identidad&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keycloakCRName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sso&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># sin esto, el correo de recuperación es superficie de ataque&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resetPasswordAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bruteForceProtected&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sslRequired&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">all&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="parte-10-lo-que-keycloak-no-resuelve">Parte 10. Lo que Keycloak no resuelve&lt;/h2>
&lt;p>&lt;strong>Autorización de grano fino sobre recursos.&lt;/strong> La pregunta &amp;ldquo;puede esta persona ver esta traza concreta&amp;rdquo; es una relación entre objetos, no un rol. Ese problema lo resuelven los motores de tipo Zanzibar, con OpenFGA y sus tuplas de objeto, relación y usuario, o un lenguaje de políticas como Cedar. Keycloak da roles y ámbitos, y con ellos no se modela un grafo de relaciones.&lt;/p>
&lt;p>&lt;strong>Secretos de máquina.&lt;/strong> Credenciales de almacenamiento, claves de proveedores, contraseñas de base de datos. Eso es un gestor de secretos, tratado en &lt;a href="https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/">el hardening del stack&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Identidad criptográfica de carga con atestación.&lt;/strong> Eso es SPIFFE y SPIRE. En Keycloak existe como proveedor de identidad en vista previa, que es otra cosa.&lt;/p>
&lt;p>&lt;strong>Autorización dentro del modelo.&lt;/strong> Qué herramienta MCP puede invocar un agente, y con qué argumentos. La especificación ofrece ámbitos, el error de ámbito insuficiente y la elevación por pasos, pero la decisión por herramienta y por argumento la toma el servidor MCP o un motor de políticas, no el proveedor de identidad.&lt;/p>
&lt;h2 id="antipatrones">Antipatrones&lt;/h2>
&lt;ol>
&lt;li>Reenviar el token del usuario desde el gateway a servidores MCP de terceros. Prohibido explícitamente por la especificación.&lt;/li>
&lt;li>Identificador de cliente estático con registro dinámico y sin consentimiento por cliente, que es la receta exacta del delegado confuso.&lt;/li>
&lt;li>Ámbitos generales, del tipo acceso total, y publicar el catálogo entero de ámbitos soportados en los metadatos.&lt;/li>
&lt;li>Tratar un manejador de estado como autenticación, que la revisión nueva prohíbe expresamente.&lt;/li>
&lt;li>Seguir el descubrimiento de OAuth hacia cualquier URL, lo que abre la puerta a peticiones hacia direcciones internas. Aplica también al servidor de autorización cuando descarga un documento de metadatos de cliente.&lt;/li>
&lt;li>Cabeceras de confianza mal expuestas en los front de chat. La documentación de Open WebUI avisa de que una configuración incorrecta permite autenticarse como cualquier usuario.&lt;/li>
&lt;li>Arrancar en modo de desarrollo en producción, con el nombre de host sin resolver de forma estricta.&lt;/li>
&lt;li>Una sola clave estática compartida como único control entre el gateway y el motor.&lt;/li>
&lt;/ol>
&lt;h2 id="checklist-de-arranque">Checklist de arranque&lt;/h2>
&lt;ol>
&lt;li>Comprobar la versión. Por debajo de 26.7.2 hay un fallo crítico de restablecimiento de contraseña.&lt;/li>
&lt;li>Construir la imagen con la fase de compilación hecha y arrancar con la bandera de optimizado.&lt;/li>
&lt;li>Nombre de host estricto, consola de administración en otro nombre, puerto de gestión separado y no expuesto.&lt;/li>
&lt;li>Decidir entre organizaciones y un realm por inquilino antes de crear el segundo inquilino.&lt;/li>
&lt;li>Un ámbito de cliente por recurso, con mapeador de audiencia, porque los indicadores de recurso no están soportados.&lt;/li>
&lt;li>En el gateway, definir las dos variables de audiencia y emisor. Sin ellas no se valida nada de eso.&lt;/li>
&lt;li>Para cargas dentro del cluster, mirar el proveedor de identidad de Kubernetes antes de repartir secretos de cliente.&lt;/li>
&lt;li>Dimensionar por picos de inicio de sesión, no por número de servicios: la diferencia es de ocho veces.&lt;/li>
&lt;li>Exportar los eventos de Keycloak al mismo sitio donde van los del gateway, porque el control de registro de actividad los necesita juntos.&lt;/li>
&lt;li>Escribir en el diseño qué hace el sistema con una tarea de dos horas, antes de que alguien resuelva el problema con un token sin conexión.&lt;/li>
&lt;/ol>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>Keycloak es una pieza madura que resuelve bien un problema acotado: saber quién es quien llama, y decirlo de forma verificable en un documento que caduca. En una plataforma de inferencia soberana eso vale mucho, porque la alternativa es un inventario de claves estáticas repartido por diez servicios que ninguna auditoría puede recorrer.&lt;/p>
&lt;p>Lo que no conviene es pedirle más. La autorización fina vive en cada servicio, la identidad de carga con atestación vive en SPIFFE, y la decisión de qué herramienta puede llamar un agente vive en el servidor MCP.&lt;/p>
&lt;p>Y hay una brecha concreta que conviene tener anotada, porque no se cierra sola: la especificación de MCP marca como obligatorios dos estándares, y de los dos que le corresponderían a un servidor de autorización, los indicadores de recurso no están implementados. El rodeo con ámbitos y mapeador de audiencia funciona, pero es un rodeo, y quien venga detrás buscará el parámetro estándar. Dejarlo escrito en el diseño ahorra una tarde.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/">El gateway no vive solo&lt;/a>: cómo valida el gateway los tokens que emite esta pieza, y las dos variables que hay que definir.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/mcp-crece-autenticacion-keycloak/">Cuando el MCP crece: ponerle autenticación con Keycloak&lt;/a>: el montaje concreto para servidores MCP.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">El gateway MCP de LiteLLM&lt;/a>: la segunda puerta de entrada, sus permisos y su falta de registro de auditoría.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/identidad-aislamiento-spiffe-confidential-containers/">Cadena de confianza del modelo (4 de 4)&lt;/a>: SPIFFE, SPIRE y la identidad de carga con atestación.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/">Hardening y secretos del stack LLM soberano&lt;/a>: dónde encaja la identidad entre las siete capas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-limites-presupuestos-claves-virtuales/">Claves virtuales, presupuestos y límites&lt;/a>: la alternativa cuando el JWT queda fuera del presupuesto.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/controles-tecnicos-ens-42001-eu-ai-act/">Controles técnicos: ENS, ISO 42001 y EU AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/completar-keycloak-para-mcp/">Completar Keycloak para MCP&lt;/a>: la continuación: el lado del recurso protegido que hay que construir, el intercambio de token y la extensión empresarial de autorización.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/2026/08/keycloak-2673-released">Keycloak 26.7.3&lt;/a>, 31 de agosto de 2026, y &lt;a href="https://www.keycloak.org/downloads">descargas&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/server/configuration">Configuración del servidor&lt;/a>, &lt;a href="https://www.keycloak.org/server/caching">caché&lt;/a>, &lt;a href="https://www.keycloak.org/server/db">bases de datos&lt;/a> y &lt;a href="https://www.keycloak.org/server/hostname">nombre de host&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/2026/01/federated-client-authentication">autenticación federada de clientes&lt;/a>, 27 de enero de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/high-availability/multi-cluster/concepts-memory-and-cpu-sizing">Dimensionado de CPU y memoria&lt;/a> e &lt;a href="https://www.keycloak.org/high-availability/introduction">introducción a alta disponibilidad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/2025/10/keycloak-benchmark">Bancos de pruebas de rendimiento de Keycloak 26.4&lt;/a>, 1 de octubre de 2025.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">Especificación de MCP, revisión 2026-07-28, autorización&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3331-structured-authentication-configuration">KEP-3331, configuración estructurada de autenticación&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.boe.es/diario_boe/xml.php?id=BOE-A-2022-7191">Real Decreto 311/2022, Anexo II&lt;/a>.&lt;/li>
&lt;li>Registro de &lt;code>CVE-2026-18963&lt;/code> en la base de datos de Red Hat y notas de la versión 26.7.3 en GitHub.&lt;/li>
&lt;li>&lt;a href="https://www.cncf.io/projects/keycloak/">CNCF, proyecto Keycloak&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>El gateway no vive solo: el JWT que no valida la audiencia, el usuario que nunca llega a la traza y las costuras que hay que escribir a mano</title><link>https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/</link><pubDate>Sat, 12 Sep 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/posts/litellm-identidad-trazas-costuras/</guid><description>&lt;blockquote>
&lt;p>Octavo artículo del track operativo de la capa de control. Los siete anteriores tratan el gateway por dentro: &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">el par con Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/posts/litellm-proxy-dia-2-alta-disponibilidad/">el día 2&lt;/a>, &lt;a href="https://blog.lo0.es/posts/litellm-limites-presupuestos-claves-virtuales/">las claves virtuales&lt;/a>, &lt;a href="https://blog.lo0.es/posts/litellm-humanos-y-agentes-mismo-gateway/">humanos y agentes&lt;/a>, &lt;a href="https://blog.lo0.es/posts/enrutado-prefijo-kv-cache-lo-que-litellm-no-hace/">el enrutado por prefijo&lt;/a>, &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">el gateway MCP&lt;/a> y &lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">el dimensionado para agentes&lt;/a>. Este trata lo que hay alrededor. Verificado contra LiteLLM 1.102.0 y Langfuse 4.35.0, ambos con commit del 11 de septiembre de 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>El gateway tiene cinco vecinos: una base de datos, una caché, un proveedor de identidad, un backend de trazas y un colector. Cada vecindad tiene su propia física, y estas son las siete cosas que decide.&lt;/p>
&lt;p>&lt;strong>El proxy arranca sin Postgres, pero entonces solo funciona la clave maestra.&lt;/strong> La variable &lt;code>DATABASE_URL&lt;/code> es opcional (&lt;code>proxy_server.py:1163&lt;/code>); sin ella, cualquier clave que no sea la maestra devuelve un 400 de conexión ausente (&lt;code>user_api_key_auth.py:1886&lt;/code>). Con base de datos configurada pero caída, el comportamiento por defecto es devolver 503, salvo que se active &lt;code>general_settings.allow_requests_on_db_unavailable&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Redis nunca es obligatorio, y esa es justo la trampa.&lt;/strong> Hay un cortacircuitos con umbral de cinco fallos y recuperación de sesenta segundos (&lt;code>constants.py:456&lt;/code>). Cuando se abre, el proxy no falla ninguna petición: degrada a memoria local. Lo que se pierde en silencio es que los límites de tasa dejen de ser globales y pasen a ser por pod.&lt;/p>
&lt;p>&lt;strong>Ningún backend de observabilidad puede tumbar una petición.&lt;/strong> Todos los callbacks se lanzan con &lt;code>asyncio.create_task&lt;/code> y sus excepciones se capturan y registran (&lt;code>litellm_logging.py:3235&lt;/code>). Si Langfuse no responde, los eventos se descartan al llenarse la cola. No hay latencia añadida, y tampoco hay registro fiable: la observabilidad del par es un dato estadístico, no un registro de auditoría.&lt;/p>
&lt;p>&lt;strong>La autenticación por JWT no valida ni la audiencia ni el emisor si no se definen dos variables.&lt;/strong> En &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>) la audiencia sale de &lt;code>JWT_AUDIENCE&lt;/code> y el emisor de &lt;code>JWT_ISSUER&lt;/code>; si están vacías, el código fija &lt;code>verify_aud=False&lt;/code> y &lt;code>verify_iss=False&lt;/code> y emite un aviso una sola vez. Cualquier token válido firmado por ese proveedor, emitido para otro servicio, es aceptado.&lt;/p>
&lt;p>&lt;strong>El control de acceso basado en roles viene desactivado.&lt;/strong> &lt;code>enforce_rbac&lt;/code> tiene valor por defecto &lt;code>False&lt;/code> (&lt;code>_types.py:4848&lt;/code> y siguientes). Con ese valor, un token cuyo rol no se resuelve a nada no produce un 403: sigue el flujo. La autorización real, entonces, la aporta la pertenencia a equipo, si es que hay un claim de equipo configurado.&lt;/p>
&lt;p>&lt;strong>El identificador de usuario que llega a Langfuse no es el de Keycloak.&lt;/strong> El campo &lt;code>user_id&lt;/code> de la traza se rellena con &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code>), es decir con el campo &lt;code>user&lt;/code> del cuerpo OpenAI, que lo pone el cliente. El sujeto del token y el dueño de la clave no viajan. Quien quiera trazabilidad de persona necesita configurar &lt;code>end_user_id_jwt_field&lt;/code> o hacer que el cliente mande ese campo.&lt;/p>
&lt;p>&lt;strong>Langfuse no aprovisiona nada desde un claim.&lt;/strong> No existe variable que mapee un claim de OIDC a organización ni a proyecto. Lo único es asignación estática al alta con &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code> y &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code>, igual para todos. Además, el control de acceso por proyecto está detrás de la licencia Enterprise en la edición self-hosted, igual que los registros de auditoría y la retención por proyecto.&lt;/p>
&lt;h2 id="estás-aquí-entre-las-cuatro-piezas">Estás aquí: entre las cuatro piezas&lt;/h2>
&lt;p>El &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">par LiteLLM y Langfuse&lt;/a> recorrió la costura entre el gateway y el backend de trazas: rutas de integración, coste que llega a cero, correlación y colas con descarte. Este artículo amplía el foco a las otras tres vecindades, con la identidad en el centro, porque es la que ordena todo lo demás y la que pide un auditor.&lt;/p>
&lt;p>La pieza de identidad en sí, es decir Keycloak, la trata &lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">el artículo siguiente&lt;/a>. Aquí se trata el lado del gateway.&lt;/p>
&lt;h2 id="la-analogía-la-obra-con-cinco-gremios">La analogía: la obra con cinco gremios&lt;/h2>
&lt;p>En una obra pequeña, el problema no suele estar dentro del trabajo de cada gremio, sino en los encuentros: donde el fontanero deja el pasatubos y el albañil cierra el tabique, donde el electricista quiere pasar por el mismo hueco. Cada gremio hace bien su parte y el encuentro queda mal resuelto porque no es de nadie.&lt;/p>
&lt;p>Una plataforma de inferencia tiene cinco gremios. El gateway sabe de tokens, el proveedor de identidad sabe de personas, el backend de trazas sabe de eventos, la base de datos sabe de gasto y la caché sabe de contadores. Cada uno tiene documentación buena. Los encuentros no tienen documentación de nadie, y ahí es donde se pierde la identidad del usuario entre el token y la traza.&lt;/p>
&lt;p>Hay un detalle más de la analogía que sirve. En la obra, la parte que más problemas da no es la que se ve mal acabada, es la que se tapa. Aquí pasa igual: las cinco costuras de este artículo fallan en silencio.&lt;/p>
&lt;h2 id="costura-1-qué-tumba-y-qué-degrada">Costura 1. Qué tumba y qué degrada&lt;/h2>
&lt;p>Conviene tener escrito qué pasa cuando cada vecino se cae, porque la intuición falla en casi todos los casos.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vecino&lt;/th>
&lt;th>¿Arranca sin él?&lt;/th>
&lt;th>Si se cae&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>Sí, solo con clave maestra&lt;/td>
&lt;td>503 por defecto; degradado con &lt;code>allow_requests_on_db_unavailable&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Cortacircuitos tras 5 fallos; límites pasan a ser por pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Se descartan eventos al llenar la cola; sin impacto en latencia&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Colector OTel&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Igual que el anterior&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Proveedor de identidad&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>Cae el acceso a la interfaz y el flujo JWT; las claves virtuales siguen&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>Sin &lt;code>DATABASE_URL&lt;/code>, &lt;code>_setup_prisma_client&lt;/code> devuelve &lt;code>None&lt;/code> sin error (&lt;code>proxy_server.py:10375&lt;/code>). El proxy sirve &lt;code>/chat/completions&lt;/code> con la clave maestra. Cualquier otra clave recibe un 400. Tampoco hay presupuesto global, y el propio arranque lo avisa: Redis no sustituye a la base de datos (&lt;code>proxy_server.py:9240&lt;/code>).&lt;/p>
&lt;p>Con base de datos configurada y caída, el manejador de excepciones relanza salvo que esté activo el flag de degradación (&lt;code>db/exception_handler.py:56&lt;/code>). Hay además un &lt;code>DISABLE_PRISMA_HEALTH_CHECK_ON_STARTUP&lt;/code> y un vigilante que reconecta. El detalle que importa para el diagnóstico: &lt;strong>un fallo transitorio de base de datos devuelve 503, no 401&lt;/strong>. Si en un incidente se ven 401, el problema es de credenciales; si se ven 503, es de base de datos o de admisión.&lt;/p>
&lt;h3 id="redis">Redis&lt;/h3>
&lt;p>El cortacircuitos está activado por defecto, con umbral de cinco fallos, recuperación de sesenta segundos y duración mínima de cinco (&lt;code>constants.py:456&lt;/code>, lógica en &lt;code>caching/redis_cache.py:153&lt;/code>). El socket tiene un timeout de 0,1 segundos (&lt;code>constants.py:453&lt;/code>). Con el circuito abierto, &lt;code>DualCache&lt;/code> sirve desde memoria sin propagar excepción (&lt;code>dual_cache.py:215&lt;/code>).&lt;/p>
&lt;p>La consecuencia hay que medirla, no suponerla: con cuatro workers y tres réplicas, un límite de cien peticiones por minuto se convierte en mil doscientas. Es un modo de fallo que no genera errores y que puede durar días sin que nadie lo note, hasta que llega la factura o el motor se satura.&lt;/p>
&lt;p>La alerta correcta no es sobre Redis, es sobre &lt;code>litellm_service_latency&lt;/code> etiquetada por servicio (&lt;code>integrations/prometheus_services.py:119&lt;/code>), que expone la latencia de Redis y de la base de datos por separado.&lt;/p>
&lt;h3 id="los-callbacks">Los callbacks&lt;/h3>
&lt;p>Todo lo que va detrás, es decir Langfuse y el colector, se lanza fuera del camino de respuesta y sus fallos se capturan. La integración clásica usa el SDK v2 con su propio hilo de fondo y un intervalo de vaciado de un segundo, ajustable con &lt;code>LANGFUSE_FLUSH_INTERVAL&lt;/code> (&lt;code>langfuse.py:198&lt;/code>). Hay un tope de cincuenta clientes Langfuse instanciados (&lt;code>constants.py:554&lt;/code>), porque cada cliente es un hilo.&lt;/p>
&lt;p>La integración por OTLP usa el procesador de lotes del SDK de OpenTelemetry (&lt;code>opentelemetry.py:3060&lt;/code>) con sus valores por defecto. En los dos casos, saturación significa descarte, y el descarte no genera error hacia el cliente.&lt;/p>
&lt;h2 id="costura-2-identidad-las-tres-puertas-y-lo-que-valida-cada-una">Costura 2. Identidad: las tres puertas y lo que valida cada una&lt;/h2>
&lt;p>Todo pasa por un constructor único de autenticación (&lt;code>user_api_key_auth.py:1245&lt;/code>), con este orden:&lt;/p>
&lt;ol>
&lt;li>Autenticación personalizada de la edición empresarial.&lt;/li>
&lt;li>Rutas públicas.&lt;/li>
&lt;li>OAuth2 opaco, si está activado, con comprobación de licencia.&lt;/li>
&lt;li>JWT, si &lt;code>general_settings.enable_jwt_auth&lt;/code> está activo y el token tiene tres partes.&lt;/li>
&lt;li>Clave maestra, con comparación en tiempo constante.&lt;/li>
&lt;li>Clave virtual, con hash SHA-256 y búsqueda en la tabla de verificación.&lt;/li>
&lt;/ol>
&lt;p>Un JWT sirve para &lt;code>/chat/completions&lt;/code>, no solo para la administración: las rutas permitidas por defecto para un equipo incluyen &lt;code>openai_routes&lt;/code> (&lt;code>_types.py:4886&lt;/code>), que contiene el endpoint de chat. La administración, en cambio, solo la alcanza un token con rol de administrador.&lt;/p>
&lt;h3 id="lo-primero-que-hay-que-saber-es-de-pago">Lo primero que hay que saber: es de pago&lt;/h3>
&lt;p>La rama JWT lleva una comprobación explícita de licencia, con el mensaje literal de que la autenticación por JWT es una función exclusivamente empresarial (&lt;code>user_api_key_auth.py:1416&lt;/code>). El SSO de la interfaz de administración es gratuito hasta cinco usuarios facturables y exige licencia por encima (&lt;code>ui_sso.py:976&lt;/code>).&lt;/p>
&lt;p>Esto condiciona la arquitectura de cualquier despliegue soberano que no quiera pagar licencia: la integración con Keycloak queda para la interfaz de administración y para el aprovisionamiento, mientras que el tráfico de inferencia se autentica con claves virtuales. Las claves virtuales no son peores, pero son otra cosa, y hay que decirlo en el análisis de riesgos: son credenciales desacopladas del proveedor de identidad, de modo que dar de baja a una persona en Keycloak no invalida su clave.&lt;/p>
&lt;h3 id="los-campos-de-litellm_jwtauth-que-deciden-la-seguridad">Los campos de &lt;code>litellm_jwtauth&lt;/code> que deciden la seguridad&lt;/h3>
&lt;p>De la clase &lt;code>LiteLLM_JWTAuth&lt;/code> (&lt;code>_types.py:4848&lt;/code> y siguientes), estos son los que importan, con sus valores por defecto:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Campo&lt;/th>
&lt;th>Defecto&lt;/th>
&lt;th>Qué hace&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>enforce_rbac&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Si es falso, un rol no resuelto no produce 403&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_scope_based_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Sin esto, &lt;code>scope_mappings&lt;/code> no se aplica&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_team_based_model_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Acceso a modelos por equipo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_jwt_field&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Claim del que sale el equipo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Si es falso, un equipo que no existe da 404&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Igual con el usuario&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>600&lt;/code>&lt;/td>
&lt;td>Caché de las claves públicas&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_stale_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>3600&lt;/code>&lt;/td>
&lt;td>Margen extra si el proveedor está caído&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>admin_jwt_scope&lt;/code>&lt;/td>
&lt;td>&lt;code>litellm_proxy_admin&lt;/code>&lt;/td>
&lt;td>Ámbito que otorga administración&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_allowed_email_domain&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Restricción por dominio de correo&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Los algoritmos aceptados son RS256/384/512, PS256/384/512, ES256/384/512 y EdDSA (&lt;code>handle_jwt.py:163&lt;/code>). No hay ninguno simétrico, lo que elimina la clase de ataque por confusión de algoritmo. Es una buena decisión de diseño y conviene reconocerla.&lt;/p>
&lt;p>La descarga de claves admite varias URL separadas por comas en &lt;code>JWT_PUBLIC_KEY_URL&lt;/code>, resuelve el descubrimiento si la URL apunta a la configuración conocida de OpenID, reintenta tres veces con espera creciente y memoriza el fallo treinta segundos (&lt;code>handle_jwt.py:680&lt;/code>, &lt;code>:751&lt;/code>, &lt;code>:88&lt;/code>). Si el proveedor está caído, sirve la copia caducada hasta la suma de los dos TTL, es decir hasta setenta minutos. Para disponibilidad está bien; para revocación no, y hay que escribirlo.&lt;/p>
&lt;p>Un detalle de la rotación de claves: con más de una clave en el juego se exige coincidencia exacta del identificador de clave, pero &lt;strong>con una sola clave y sin identificador se acepta sin comprobar&lt;/strong> (&lt;code>handle_jwt.py:913&lt;/code>).&lt;/p>
&lt;h3 id="la-validación-que-no-ocurre">La validación que no ocurre&lt;/h3>
&lt;p>Esta es la parte que hay que corregir el primer día. En &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>), la audiencia se toma de la variable de entorno &lt;code>JWT_AUDIENCE&lt;/code> y el emisor de &lt;code>JWT_ISSUER&lt;/code>. Si no están definidas, el código fija &lt;code>verify_aud=False&lt;/code> y &lt;code>verify_iss=False&lt;/code>, y emite un aviso una sola vez.&lt;/p>
&lt;p>Lo que eso significa en un despliegue real con Keycloak: un token emitido para el cliente de Grafana, o para el de Backstage, firmado por el mismo realm, es aceptado por el gateway como credencial válida. La caducidad sí se valida siempre, con margen cero. La firma también. La audiencia no.&lt;/p>
&lt;p>Hay una segunda vía, &lt;code>issuers&lt;/code>, con configuración por emisor, que sí valida emisor y audiencia salvo que se desactive explícitamente (&lt;code>handle_jwt.py:1186&lt;/code>). Es la que hay que usar cuando hay más de un proveedor.&lt;/p>
&lt;p>La corrección mínima son dos líneas de entorno:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y del lado de Keycloak, un mapeador de audiencia en un client scope dedicado que inyecte esa audiencia. El detalle está &lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">en el artículo de Keycloak&lt;/a>, porque el estándar que resolvería esto de forma limpia, los indicadores de recurso, no está soportado.&lt;/p>
&lt;h3 id="del-claim-al-permiso">Del claim al permiso&lt;/h3>
&lt;p>El rol de administrador sale de que &lt;code>admin_jwt_scope&lt;/code> aparezca en el claim &lt;code>scope&lt;/code>, y devuelve resultado sin pasar por equipo (&lt;code>handle_jwt.py:309&lt;/code>, &lt;code>:1373&lt;/code>). El resto se resuelve por equipo.&lt;/p>
&lt;p>Si el equipo del claim no existe en la base de datos, con &lt;code>team_id_upsert&lt;/code> en falso se devuelve un 404 con el mensaje de que hay que crear el equipo (&lt;code>auth_checks.py:3035&lt;/code>). Con el flag activo, se crea invocando el alta de equipo con una identidad sintética de administrador (&lt;code>auth_checks.py:2874&lt;/code>). Esa es la única vía de aprovisionamiento automático que existe de Keycloak hacia el gateway, y funciona bien. Conviene saber que crea equipos sin presupuesto ni límites, de modo que hace falta un valor por defecto en la configuración.&lt;/p>
&lt;h3 id="la-vulnerabilidad-que-conviene-conocer">La vulnerabilidad que conviene conocer&lt;/h3>
&lt;p>&lt;code>CVE-2026-35030&lt;/code>, con puntuación 9,1 en CVSS 3.1 y 9,4 en CVSS 4.0, aviso &lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code>. Con la autenticación por JWT activa, la caché del endpoint de información de usuario de OIDC usaba los veinte primeros caracteres del token como clave, de modo que dos tokens con el mismo prefijo se confundían. Corregida en 1.83.0.&lt;/p>
&lt;p>En 1.102.0 el código usa el hash SHA-256 completo (&lt;code>handle_jwt.py:962&lt;/code>). Una búsqueda de patrones parecidos en el árbol no encuentra ninguna caché de autenticación indexada por prefijo de token: los recortes que quedan son enmascarados para el registro. Y merece recordarse lo ya publicado &lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">sobre el gateway MCP&lt;/a>: por debajo de 1.83.14 hay ejecución remota o inyección SQL sin autenticar.&lt;/p>
&lt;h2 id="costura-3-la-identidad-no-llega-a-la-traza">Costura 3. La identidad no llega a la traza&lt;/h2>
&lt;p>Aquí está el fallo de diseño más caro de la figura, porque se descubre en la primera auditoría.&lt;/p>
&lt;p>El campo &lt;code>user_id&lt;/code> de una traza de Langfuse se rellena con &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code> y &lt;code>:724&lt;/code>), que proviene del campo &lt;code>user&lt;/code> del cuerpo de la petición OpenAI o de &lt;code>end_user_id_jwt_field&lt;/code> (&lt;code>litellm_pre_call_utils.py:1584&lt;/code>). No es el sujeto del token ni el propietario de la clave virtual.&lt;/p>
&lt;p>Las consecuencias en cadena:&lt;/p>
&lt;ul>
&lt;li>Si el cliente no manda &lt;code>user&lt;/code>, la traza no tiene usuario.&lt;/li>
&lt;li>Si el cliente manda &lt;code>user&lt;/code>, la traza tiene el valor que el cliente quiera poner, sin verificar.&lt;/li>
&lt;li>Un agente que llama con una clave de servicio deja trazas sin persona detrás.&lt;/li>
&lt;/ul>
&lt;p>Hay tres formas de arreglarlo, en orden de robustez:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Configurar &lt;code>end_user_id_jwt_field&lt;/code>&lt;/strong> para que el usuario final salga de un claim del token y no del cuerpo. Es la única opción en la que el valor no lo controla el cliente, y exige autenticación por JWT, es decir licencia.&lt;/li>
&lt;li>&lt;strong>Forzar el metadato &lt;code>trace_user_id&lt;/code>&lt;/strong>, que sobrescribe el campo (&lt;code>langfuse.py:726&lt;/code>; en la vía OTLP, &lt;code>langfuse_otel.py:96&lt;/code>). Se puede fijar por equipo.&lt;/li>
&lt;li>&lt;strong>Obligar por contrato a que el cliente mande &lt;code>user&lt;/code>&lt;/strong> y validar en un guardrail. Funciona, pero es una declaración del cliente, no una prueba.&lt;/li>
&lt;/ol>
&lt;p>Hacia el motor pasa algo parecido. La identidad del llamante no viaja a vLLM salvo que se active &lt;code>litellm.add_user_information_to_llm_headers&lt;/code>, cuyo valor por defecto es &lt;code>None&lt;/code> (&lt;code>litellm/__init__.py:223&lt;/code>). Con ella se inyectan cabeceras &lt;code>x-litellm-*&lt;/code> con identificador de usuario, de equipo y hash de clave (&lt;code>litellm_pre_call_utils.py:1356&lt;/code>). El reenvío de cabeceras del cliente exige &lt;code>general_settings.forward_client_headers_to_llm_api&lt;/code>, y la cabecera &lt;code>authorization&lt;/code> se filtra siempre, lo cual es correcto.&lt;/p>
&lt;p>Hacia los servidores MCP, por defecto se usa la credencial propia del servidor, no el token del usuario. Solo dos modos reenvían el token del llamante, y hacerlo tiene un nombre en la especificación de MCP: paso de token, que está explícitamente prohibido. Vuelve a salir &lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">en el artículo de Keycloak&lt;/a>.&lt;/p>
&lt;h2 id="costura-4-langfuse-como-vecino">Costura 4. Langfuse como vecino&lt;/h2>
&lt;h3 id="lo-que-se-puede-automatizar-y-lo-que-no">Lo que se puede automatizar y lo que no&lt;/h3>
&lt;p>La lista exacta de variables para SSO en Langfuse 4.x, tomada de &lt;code>web/src/env.mjs&lt;/code>:&lt;/p>
&lt;p>Con proveedor Keycloak: &lt;code>AUTH_KEYCLOAK_CLIENT_ID&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_SECRET&lt;/code>, &lt;code>AUTH_KEYCLOAK_ISSUER&lt;/code>, &lt;code>AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_AUTH_METHOD&lt;/code>, &lt;code>AUTH_KEYCLOAK_CHECKS&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG&lt;/code>, &lt;code>AUTH_KEYCLOAK_SCOPE&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN&lt;/code>, &lt;code>AUTH_KEYCLOAK_NAME&lt;/code>.&lt;/p>
&lt;p>Con OIDC genérico: el mismo juego con prefijo &lt;code>AUTH_CUSTOM_&lt;/code>, más &lt;code>AUTH_CUSTOM_FETCH_USERINFO&lt;/code> y el mapeo de claims con &lt;code>LANGFUSE_CUSTOM_SSO_SUB_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_NAME_CLAIM&lt;/code> y &lt;code>LANGFUSE_CUSTOM_SSO_IMAGE_CLAIM&lt;/code>.&lt;/p>
&lt;p>Globales: &lt;code>AUTH_DISABLE_USERNAME_PASSWORD&lt;/code>, &lt;code>AUTH_DISABLE_SIGNUP&lt;/code>, &lt;code>AUTH_DOMAINS_WITH_SSO_ENFORCEMENT&lt;/code>, &lt;code>AUTH_SESSION_MAX_AGE&lt;/code>.&lt;/p>
&lt;p>Y ahora lo que no hay. &lt;strong>No existe ninguna variable que mapee un claim a organización o proyecto.&lt;/strong> Lo único es asignación estática al alta con &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code>, &lt;code>LANGFUSE_DEFAULT_ORG_ROLE&lt;/code> con valor por defecto &lt;code>VIEWER&lt;/code>, &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code> y &lt;code>LANGFUSE_DEFAULT_PROJECT_ROLE&lt;/code> (&lt;code>features/auth/lib/createProjectMembershipsOnSignup.ts:61&lt;/code>). La propia documentación lo confirma: el SSO empresarial no aprovisiona roles automáticamente en el alta.&lt;/p>
&lt;p>Además, en la tabla de permisos por edición (&lt;code>features/entitlements/constants/entitlements.ts:139&lt;/code>), las ediciones abierta y profesional self-hosted &lt;strong>no incluyen&lt;/strong> el control de acceso por proyecto ni la API de administración. Son de la edición empresarial, igual que los registros de auditoría y la retención por proyecto. En la edición abierta, todo usuario hereda el rol de su organización.&lt;/p>
&lt;h3 id="el-arranque-declarativo-que-sí-existe">El arranque declarativo, que sí existe&lt;/h3>
&lt;p>Para on-premise sin licencia, la vía practicable es el bootstrap por variables (&lt;code>web/src/initialize.ts&lt;/code>), que es idempotente: &lt;code>LANGFUSE_INIT_ORG_ID&lt;/code> (obligatorio, si falta se ignora el resto con un aviso), &lt;code>LANGFUSE_INIT_ORG_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_ID&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_RETENTION&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_SECRET_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_USER_EMAIL&lt;/code>, &lt;code>LANGFUSE_INIT_USER_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_USER_PASSWORD&lt;/code>.&lt;/p>
&lt;p>Con eso, un proyecto por inquilino se crea desde GitOps con un contenedor de inicialización por inquilino y las claves en secretos de Kubernetes.&lt;/p>
&lt;h3 id="credenciales-de-langfuse-por-equipo">Credenciales de Langfuse por equipo&lt;/h3>
&lt;p>Esta parte sí está bien resuelta del lado del gateway y poca gente la usa. &lt;code>TeamCallbackMetadata.callback_vars&lt;/code> (&lt;code>_types.py:2152&lt;/code>) admite &lt;code>langfuse_public_key&lt;/code>, &lt;code>langfuse_secret_key&lt;/code>, &lt;code>langfuse_host&lt;/code> y &lt;code>langfuse_environment&lt;/code>, con lista blanca en &lt;code>initialize_dynamic_callback_params.py:67&lt;/code>. El gateway cachea un registrador por juego de credenciales (&lt;code>langfuse_handler.py:24&lt;/code>) y, en la vía OTLP, construye un exportador completo por clave (&lt;code>langfuse_otel.py:400&lt;/code>).&lt;/p>
&lt;p>Es decir: &lt;strong>un proyecto de Langfuse por equipo de LiteLLM es posible hoy&lt;/strong>, sin licencia, y es la forma correcta de aislar trazas entre inquilinos. Lo que no existe es la creación automática del proyecto, ni la correspondencia entre el equipo y el proyecto. Eso es pegamento que hay que escribir.&lt;/p>
&lt;h3 id="la-ruta-de-ingesta">La ruta de ingesta&lt;/h3>
&lt;p>Langfuse acepta &lt;strong>únicamente OTLP sobre HTTP&lt;/strong>, en &lt;code>http/protobuf&lt;/code> o &lt;code>http/json&lt;/code>. gRPC no está soportado. La ruta base es &lt;code>/api/public/otel&lt;/code> y la señal vive en &lt;code>/api/public/otel/v1/traces&lt;/code>, verificado en el repositorio. La autenticación es Basic con el par de claves del proyecto, más la cabecera &lt;code>x-langfuse-ingestion-version: 4&lt;/code> para la ruta de la versión 4.&lt;/p>
&lt;p>El gateway lo construye exactamente así (&lt;code>langfuse_otel.py:328&lt;/code>, &lt;code>:347&lt;/code>, &lt;code>:359&lt;/code>) y luego normaliza el endpoint añadiendo el sufijo de la señal (&lt;code>opentelemetry.py:3237&lt;/code>). El resultado coincide con la ruta real, así que la integración funciona sin tocar nada. El detalle importa cuando se mete un colector en medio y alguien copia el endpoint a mano.&lt;/p>
&lt;p>Y el aviso de calendario que ya salió &lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">en el par operativo&lt;/a> sigue en pie: el callback clásico está atado al SDK v2 y la fecha marcada es el 16 de noviembre de 2026. Con un matiz que conviene leer antes de programar una migración de urgencia, y que se detalla &lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">en el artículo del modelo de datos&lt;/a>: esa fecha rige para Langfuse Cloud, y en el código de la versión self-hosted la ruta antigua no se apaga, cambia de comportamiento según el modo de escritura.&lt;/p>
&lt;h3 id="cuándo-meter-un-colector">Cuándo meter un colector&lt;/h3>
&lt;p>Directo si Langfuse es el único destino de trazas. Vía colector si hay más de un consumidor, o si hace falta alguna de estas tres cosas que el procesador de lotes del proxy no da:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Reintentos con espera creciente y cola en disco&lt;/strong>, con los procesadores &lt;code>sending_queue&lt;/code> y &lt;code>file_storage&lt;/code>. Con eso, una ventana de mantenimiento de Langfuse deja de perder trazas.&lt;/li>
&lt;li>&lt;strong>Muestreo de cola&lt;/strong>, que permite quedarse con el 100 % de los errores y el 1 % del resto. El proxy no puede muestrear por resultado, porque cuando decide ya no sabe cómo acabó.&lt;/li>
&lt;li>&lt;strong>Redacción de atributos&lt;/strong> antes de que salgan del espacio de nombres. Es la única capa donde se pueden borrar los argumentos de herramienta MCP que, como ya se documentó, se escriben en claro saltándose el interruptor de redacción de mensajes.&lt;/li>
&lt;/ul>
&lt;h2 id="costura-5-postgres-redis-y-la-política-de-memoria">Costura 5. Postgres, Redis y la política de memoria&lt;/h2>
&lt;p>&lt;strong>Postgres separado, siempre.&lt;/strong> Cada pieza aplica sus propias migraciones de Prisma sobre el esquema público de la base que se le indique. Dos clientes de Prisma migrando el mismo esquema colisionan por nombre de tabla y por historial de migraciones. Además Langfuse 4.x exige Postgres 15 como mínimo, recomienda 16 y quiere UTC por defecto. El perfil de carga también es opuesto: el gateway escribe registros de gasto a alta frecuencia, mientras que Langfuse hace transaccional ligero porque los datos de traza van a ClickHouse.&lt;/p>
&lt;p>&lt;strong>Redis separado, y el argumento no es el que parece.&lt;/strong> No hay colisión de claves por diseño: el gateway usa etiquetas de hash con la forma &lt;code>{api_key:...}:requests&lt;/code> y un espacio de nombres opcional, y Langfuse usa el prefijo de BullMQ más un prefijo propio para su caché de claves. El argumento decisivo es otro: &lt;strong>Langfuse exige &lt;code>maxmemory-policy=noeviction&lt;/code>&lt;/strong> porque sus colas son datos, no caché, mientras que el gateway asume caché desechable. Compartir instancia significa que un pico de claves de límite de tasa puede llenar la memoria y provocar errores de escritura en las colas de ingesta en lugar de un desalojo inofensivo.&lt;/p>
&lt;p>Si aun así se comparte, dos medidas mínimas: &lt;code>REDIS_KEY_PREFIX&lt;/code> en Langfuse, &lt;code>namespace&lt;/code> en el gateway, y bases lógicas distintas.&lt;/p>
&lt;p>Y un recordatorio de superficie: Langfuse 4.x &lt;strong>no arranca sin ClickHouse ni sin bucket&lt;/strong> compatible con S3. &lt;code>CLICKHOUSE_URL&lt;/code> y &lt;code>LANGFUSE_S3_EVENT_UPLOAD_BUCKET&lt;/code> son obligatorios y sin valor por defecto (&lt;code>packages/shared/src/env.ts:120&lt;/code> y &lt;code>:271&lt;/code>). Su superficie de fallo es mucho mayor que la del gateway, y eso cambia dónde poner el esfuerzo de alta disponibilidad.&lt;/p>
&lt;h2 id="puertos-para-la-networkpolicy">Puertos, para la NetworkPolicy&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Pieza&lt;/th>
&lt;th>Puerto&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM (API, interfaz y métricas)&lt;/td>
&lt;td>4000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse web&lt;/td>
&lt;td>3000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse worker&lt;/td>
&lt;td>3030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ClickHouse&lt;/td>
&lt;td>8123 HTTP, 9000 nativo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Almacenamiento S3 compatible&lt;/td>
&lt;td>9000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>6379&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Colector OTel&lt;/td>
&lt;td>4317 gRPC, 4318 HTTP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Los pares que hay que permitir con una política de denegación por defecto, siguiendo el método de &lt;a href="https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/">hardening del stack&lt;/a>:&lt;/p>
&lt;pre tabindex="0">&lt;code>litellm → postgres:5432, redis:6379
litellm → collector:4318 (o langfuse-web:3000 si va directo)
litellm → vllm:8000
litellm → keycloak:8443 (solo interfaz y flujo JWT)
collector → langfuse-web:3000
langfuse-web → postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-worker→ postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-web → keycloak:8443
ingress → litellm:4000, langfuse-web:3000
prometheus → litellm:4000
&lt;/code>&lt;/pre>&lt;p>El worker de Langfuse no necesita tráfico de entrada más allá de la sonda de salud.&lt;/p>
&lt;h2 id="el-pegamento-que-hay-que-escribir">El pegamento que hay que escribir&lt;/h2>
&lt;p>Puesto en una tabla, el estado real de la cadena de inquilinos:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tramo&lt;/th>
&lt;th>¿Automático?&lt;/th>
&lt;th>Cómo&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Keycloak → equipo de LiteLLM&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>&lt;code>team_ids_jwt_field&lt;/code> más &lt;code>team_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → usuario de LiteLLM&lt;/td>
&lt;td>Sí&lt;/td>
&lt;td>&lt;code>user_id_jwt_field&lt;/code> más &lt;code>user_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → organización de Langfuse&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Solo asignación estática al alta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → proyecto de Langfuse&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Igual&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Equipo de LiteLLM → proyecto de Langfuse&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>callback_vars&lt;/code> a mano, o un operador propio&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Usuario de Keycloak → &lt;code>user_id&lt;/code> de la traza&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>end_user_id_jwt_field&lt;/code>, o contrato con el cliente&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>La arquitectura mínima viable sin licencia, entonces, es esta:&lt;/p>
&lt;ol>
&lt;li>Un proyecto de Langfuse por inquilino, creado con las variables de inicialización desde GitOps.&lt;/li>
&lt;li>Las claves de ese proyecto en un secreto de Kubernetes.&lt;/li>
&lt;li>Un trabajo periódico propio que lea los equipos por la API de administración del gateway y escriba &lt;code>callback_vars&lt;/code> con las claves correspondientes.&lt;/li>
&lt;li>Las personas entrando a Langfuse por Keycloak, pero asignadas a mano a su organización.&lt;/li>
&lt;li>El identificador de usuario final resuelto por contrato con los clientes, y validado.&lt;/li>
&lt;/ol>
&lt;p>Son unas ciento cincuenta líneas de código propio. Conviene presupuestarlas desde el principio, porque el hueco no se cierra solo.&lt;/p>
&lt;h2 id="configuración-de-referencia">Configuración de referencia&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">general_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># solo con licencia; sin ella, claves virtuales&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_jwtauth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_ids_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">end_user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;preferred_username&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">user_allowed_email_domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ejemplo.es&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">admin_jwt_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm_proxy_admin&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># los tres que vienen apagados de fábrica&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_scope_based_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">allow_requests_on_db_unavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">callbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># sin esto, el motor no sabe quién llama&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">add_user_information_to_llm_headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># las dos que activan la validación que por defecto no ocurre&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_PUBLIC_KEY_URL&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma/.well-known/openid-configuration
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_HOST&lt;/span>&lt;span class="o">=&lt;/span>http://langfuse-web.observabilidad.svc:3000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_PUBLIC_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SECRET_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Y del lado de Langfuse:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_CLIENT_ID&lt;/span>&lt;span class="o">=&lt;/span>langfuse
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_USERNAME_PASSWORD&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_SIGNUP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ID&lt;/span>&lt;span class="o">=&lt;/span>plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ROLE&lt;/span>&lt;span class="o">=&lt;/span>VIEWER
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ENCRYPTION_KEY&lt;/span>&lt;span class="o">=&lt;/span>... &lt;span class="c1"># 64 caracteres hexadecimales&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Definir &lt;code>JWT_AUDIENCE&lt;/code> y &lt;code>JWT_ISSUER&lt;/code> el primer día, o usar la configuración por emisor. Sin eso no se valida ni audiencia ni emisor.&lt;/li>
&lt;li>Poner &lt;code>enforce_rbac&lt;/code>, &lt;code>enforce_scope_based_access&lt;/code> y &lt;code>enforce_team_based_model_access&lt;/code> en cierto, y probar que un token de otro cliente del mismo realm es rechazado.&lt;/li>
&lt;li>Decidir de dónde sale el usuario final de las trazas y escribirlo en el diseño. Si no se decide, las trazas salen sin persona.&lt;/li>
&lt;li>Separar Postgres. Separar Redis. Si se comparte Redis, prefijo y base lógica distintos, y revisar la política de memoria.&lt;/li>
&lt;li>Comprobar que el proxy arranca con Postgres caído solo si eso es lo que se quiere, y que el equipo de guardia sabe distinguir un 503 de admisión de un 401 de credencial.&lt;/li>
&lt;li>Alertar sobre &lt;code>litellm_service_latency&lt;/code> por servicio, no sobre la disponibilidad de Redis, porque el cortacircuitos oculta el problema.&lt;/li>
&lt;li>Un proyecto de Langfuse por inquilino con las variables de inicialización, y las claves por equipo en &lt;code>callback_vars&lt;/code>.&lt;/li>
&lt;li>Meter un colector en medio si hace falta cola en disco, muestreo de cola o redacción de atributos.&lt;/li>
&lt;li>Revisar que la versión del gateway está por encima de 1.83.14 por las vulnerabilidades críticas anteriores.&lt;/li>
&lt;li>Escribir en el análisis de riesgos que las claves virtuales son credenciales desacopladas del proveedor de identidad, y definir el procedimiento de baja.&lt;/li>
&lt;/ol>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>Un JWT bien firmado no es un JWT dirigido a este servicio.&lt;/strong> Sin las dos variables de entorno, la audiencia no se comprueba.&lt;/p>
&lt;p>&lt;strong>&lt;code>enforce_rbac&lt;/code> en falso no significa que no haya autorización&lt;/strong>, significa que la autorización la aporta solo la pertenencia a equipo, si hay claim de equipo. Sin claim de equipo y sin el flag, un token válido llega a &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>&lt;strong>La copia caducada de las claves públicas dura hasta setenta minutos&lt;/strong>, sumando los dos TTL. Revocar una clave en el proveedor no surte efecto inmediato.&lt;/p>
&lt;p>&lt;strong>Con una sola clave en el JWKS y sin identificador de clave, no se comprueba el identificador.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>El usuario de la traza lo pone el cliente.&lt;/strong> Cualquier informe de gasto por persona construido sobre ese campo es una declaración, no una medida.&lt;/p>
&lt;p>&lt;strong>La caída de Redis no genera errores&lt;/strong>, genera límites por pod. Es el fallo más caro de los que no avisan.&lt;/p>
&lt;p>&lt;strong>Langfuse no arranca sin ClickHouse ni sin bucket S3.&lt;/strong> Su superficie de fallo es mayor que la del gateway, y eso cambia el reparto del esfuerzo de alta disponibilidad.&lt;/p>
&lt;p>&lt;strong>El control de acceso por proyecto de Langfuse es de pago en self-hosted&lt;/strong>, igual que los registros de auditoría. Para un expediente de ENS, eso se decide antes de montar, no después.&lt;/p>
&lt;p>&lt;strong>Compartir Redis no rompe por colisión de claves, rompe por política de memoria.&lt;/strong> Es un fallo que aparece bajo carga y que se diagnostica mal.&lt;/p>
&lt;p>&lt;strong>La ruta de ingesta de Langfuse es solo OTLP sobre HTTP.&lt;/strong> Un colector configurado con el exportador gRPC apunta a un sitio que no existe.&lt;/p>
&lt;h2 id="cierre">Cierre&lt;/h2>
&lt;p>Las cinco costuras tienen un patrón común: fallan sin avisar. El token de otro servicio entra, el usuario de la traza viene del cliente, el límite de tasa deja de ser global, la cola de gasto descarta al llegar a 64 MB y el proyecto de Langfuse no se crea solo. Ninguna de esas cinco cosas produce un error visible.&lt;/p>
&lt;p>El trabajo del día 2, entonces, consiste sobre todo en convertir silencios en señales. Dos variables de entorno para que la audiencia se valide, tres flags para que la autorización sea real, una alerta sobre la latencia de servicio en lugar de sobre la disponibilidad de Redis, y un contrato explícito sobre de dónde sale la identidad del usuario final.&lt;/p>
&lt;p>Y una decisión que conviene tomar pronto, antes de que el presupuesto esté cerrado. La autenticación por JWT del gateway y el control de acceso por proyecto de Langfuse están los dos detrás de licencia. Una plataforma soberana puede vivir perfectamente sin ambas, con claves virtuales y un proyecto por inquilino, pero es una arquitectura distinta y hay que dibujarla como tal desde el principio.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-langfuse-par-operativo/">LiteLLM y Langfuse: el par operativo&lt;/a>: la costura entre gateway y trazas, con las cuatro colas que descartan.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/dimensionar-gateway-flota-agentes/">Dimensionar para agentes&lt;/a>: el artículo anterior del track, con el tamaño de cada pieza.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/keycloak-plano-identidad-plataforma-ia/">Keycloak en una plataforma de IA&lt;/a>: la pieza de identidad por dentro y su posición en la arquitectura.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/litellm-gateway-mcp-segunda-puerta/">El gateway MCP de LiteLLM&lt;/a>: la segunda puerta, con sus permisos y su problema de privacidad.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/langfuse-self-hosting-arquitectura-tuning/">Langfuse por dentro&lt;/a> y &lt;a href="https://blog.lo0.es/posts/langfuse-v4-que-entra-en-una-traza/">qué entra en una traza&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/">Hardening y secretos del stack LLM soberano&lt;/a>: el método de denegación por defecto que ordena la tabla de puertos.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/mcp-crece-autenticacion-keycloak/">Cuando el MCP crece: ponerle autenticación con Keycloak&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="fuentes">Fuentes&lt;/h2>
&lt;ul>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/authentication-and-sso">Autenticación y SSO en Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/scim-and-org-api">SCIM y API de organización&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración OpenTelemetry de Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/postgres">Postgres&lt;/a> y &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/cache">caché&lt;/a> en la documentación de self-hosting, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">Autenticación por token en LiteLLM&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">ajustes de configuración&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code> y registro de &lt;code>CVE-2026-35030&lt;/code> en NVD.&lt;/li>
&lt;/ul></description></item></channel></rss>