La clave que expira: darle a un agente su propia identidad en el gateway

- 1Lock down Docker networks
- 2Run containers as non-root
- 3Identity in front of every port
- 4Membership, not just an account
- 5An identity for the agent, not a key
- 6A tool server that checks who is asking
- 7Where the agent can go, not just what it can call
- 8Move the daemon off root
- 9Run the model's own code without trusting it
- 🏆A scope per tool, and a refusal clients can act on
La Parte 4 puso control de acceso por grupos sobre la interfaz de administración del gateway y después, al final, llamó a la API desde un shell sin cuenta, sin sesión y sin grupo. Respondió con normalidad. La conclusión fue que el SSO protege un plano de control y que el plano de datos autentica a quien llama por máquina con claves — cosa que tiene que hacer, porque un agente que corre a las 3 de la mañana no puede completar un login en un navegador.
Eso era cierto y era también un punto y aparte, no una respuesta. "Los clientes máquina usan claves" te deja con una credencial que nunca expira, que ningún proveedor de identidad conoce, y que sobrevive a la persona que la creó. La Parte 4 lo dijo sin rodeos: sacar a alguien de un grupo no revoca sus claves, una clave filtrada no se ve afectada por la identidad en absoluto, y las claves sobreviven a las personas.
Esta parte le da a la máquina una identidad en lugar de una clave.
authentik le emite al agente un token con el flujo de client credentials — sin navegador, sin pantalla de consentimiento, sin humano. El token va firmado, lleva un scope y expira a los cinco minutos. El gateway lo verifica localmente contra las claves públicas de authentik y rechaza cualquier cosa sin el scope correcto. Al final, tres códigos de estado enseñan el límite aguantando.
Una WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15. authentik
2026.8.1, la misma instancia de las Partes 3 y 4 en el puerto host 9100. LiteLLM 1.96.2
de la Parte 1 de la serie del gateway,
enlazado a 127.0.0.1:4000, con los modelos servidos por WEC Inference. La verificación usa
PyJWT 2.13.0 y cryptography 50.0.0, ambos ya presentes en la imagen de LiteLLM.
HTTP plano sobre una LAN privada, como en las Partes 3 y 4. Sustituye las direcciones por las tuyas.
Dos flujos, dos preguntas distintas
Las Partes 3 y 4 usaron el flujo de authorization code de principio a fin. Una persona pulsa un botón, se le redirige a authentik, escribe una contraseña, ve una pantalla de consentimiento, y vuelve con un código que la aplicación intercambia por un token. Cada paso de eso da por hecho un navegador y una persona sentada delante.
El flujo de client credentials responde otra pregunta. No hay usuario. El cliente es el principal, y lo demuestra con un ID y un secreto, en una sola petición, sin redirección a ninguna parte.
La propiedad que merece atención: el gateway nunca le pregunta a authentik si un token es bueno. Descarga las claves públicas de authentik una vez, las cachea, y comprueba la firma ella misma. La identidad escala sin que el proveedor de identidad se convierta en un cuello de botella — ni en un punto único de fallo en cada petición.
Requisitos previos
- Un authentik funcionando, idealmente el de la Parte 3
- Un gateway LiteLLM en marcha, de la Parte 1 de la serie del gateway
- Su
LITELLM_MASTER_KEY, y acceso por shell a la máquina
Paso 1 — Un cliente con exactamente un flujo
Applications → Providers → New Provider → OAuth2/OpenID Provider → Next.
- Provider Name —
agent-gateway - Authorization Flow — deja
default-provider-authorization-explicit-consent. Es obligatorio, y aquí es inerte: gobierna la pantalla de consentimiento que ve una persona, y ninguna persona va a usar este cliente. Aun así tienes que elegir uno. - Client Type — Confidential. La propia descripción de authentik es la razón: "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets." Client credentials no tiene nada más que ese secreto para demostrar identidad, así que un cliente público no puede usar este flujo en absoluto.
- Redirect URIs — déjalo vacío. Aquí nunca se redirige nada a ninguna parte.
Después baja hasta Grant Types, y mira con qué viene un proveedor nuevo.

Siete de ocho, activados por defecto. Dos de ellos — Implicit y Password — no llegaron a OAuth 2.1, que dice claramente que "some features available in OAuth 2.0, such as the Implicit or Resource Owner Credentials grant types, are not specified in OAuth 2.1" (§10.1). Password en concreto hace que el cliente maneje la contraseña real del usuario, y por eso cayó en desuso. Nadie te preguntó si los querías y nada te avisa de que están puestos.
Desmarca todo excepto Client credentials.

Es el mismo reflejo de mínimo privilegio que la Parte 4 aplicó a la pertenencia a grupos, una capa más abajo. Un cliente que solo va a usar un flujo debería tener permitido solo un flujo, para que una mala configuración en otro sitio no pueda convertirlo en un endpoint que acepta contraseñas.
Esos valores por defecto se aplicaron también a todos los proveedores que creaste en las Partes 3 y 4, y nada los ha recortado desde entonces. Pregúntale a la base de datos, no a la interfaz:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.providers.oauth2.models import OAuth2Provider
for p in OAuth2Provider.objects.all():
print(f'{p.name}: {p.grant_types}')" 2>/dev/null | tail -5
Provider for Langfuse: ['authorization_code', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code']
Provider for LiteLLM: ['authorization_code', 'implicit', 'hybrid', 'refresh_token', 'client_credentials', 'password', 'urn:ietf:params:oauth:grant-type:device_code']
agent-gateway: ['client_credentials']
El proveedor del gateway sigue teniendo password y client_credentials además del flujo
que realmente usa. Ninguno hacía falta, y el flujo de contraseña en particular es una vía de
entrada viva que nadie puso a propósito. Recorta cada proveedor a los flujos que usa.
Lee el texto de ayuda del propio authentik en el campo Redirect URIs: "If no explicit authorization redirect URIs are specified, the first successfully used authorization redirect URI will be saved."
Vacío no significa "denegar todo". Significa confianza en el primer uso — authentik fija la
redirección que aparezca primero y la conserva. Eso es inofensivo para un cliente que nunca
redirige, que es nuestro caso. No es inofensivo en un proveedor interactivo, donde significa
que no tienes ninguna validación de redirección hasta que alguien inicia sesión y te la fija.
La Parte 3 avisaba de poner el modo en .*; esta es la versión silenciosa del mismo peligro.
Paso 2 — La aplicación a la que pertenece
Lee las credenciales e intenta obtener un token. Todavía no va a funcionar, y la razón es lo más útil de este post.
mkdir -p ~/agent-auth
cd ~/agent-auth
umask 077
cat > .env <<EOF
AK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/
AK_CLIENT_ID=<el client id de la página del proveedor>
AK_CLIENT_SECRET=<el client secret de la página del proveedor>
EOF
umask 077 crea el fichero con permisos 600 en vez de hacer chmod después, así que no hay
ninguna ventana en la que el secreto sea legible por todo el mundo.
Un pequeño ayudante, porque vas a pedir tokens constantemente:
cat > ~/agent-auth/token.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/.env"
curl -sS -X POST "$AK_TOKEN_URL" \
-d grant_type=client_credentials \
-d client_id="$AK_CLIENT_ID" \
-d client_secret="$AK_CLIENT_SECRET" \
-d scope="${1:-}" \
| jq -r .access_token
EOF
chmod +x ~/agent-auth/token.sh
Pide un token:
cd ~/agent-auth
source .env
curl -sS -X POST "$AK_TOKEN_URL" \
-d grant_type=client_credentials \
-d client_id="$AK_CLIENT_ID" \
-d client_secret="$AK_CLIENT_SECRET" | jq
{
"error": "invalid_grant",
"error_description": "The provided authorization grant or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"request_id": "757c397b97a745259e288b6d4ff1553c"
}
Cada cláusula de esa descripción es falsa en nuestro caso. Nada ha expirado, nada está revocado, no se usó ninguna redirección, y el cliente es el dueño de esas credenciales. El log del servidor registra el 400 y ninguna razón. Los Events de authentik tampoco enseñan nada útil.
La causa real se ve en la lista de Providers, que lo marca sin disimulo:

Un proveedor sin aplicación no puede emitir un token. authentik autoriza contra la
aplicación, así que un proveedor por su cuenta no tiene contra qué autorizar — y dice
invalid_grant en lugar de decir eso.
Esto no está en la documentación de client credentials de authentik, que documenta cinco formas de autenticarse — tres con credenciales estáticas y dos con JWT — y no dice nada sobre necesitar una aplicación.
Empareja una. Fíjate en la ruta: el asistente de New Application siempre crea un proveedor nuevo, así que no puede adoptar el que acabamos de construir.

Su lista de pasos lo delata — Choose a Provider es el paso 2, y todas las opciones de ahí construyen uno nuevo. La flecha junto al botón es donde vive el otro camino.
Applications → Applications → New Application ▾ → with Existing Provider…
- Name —
Agent Gateway - Slug —
agent-gateway, escrito a mano. El hallazgo de la Parte 4 fue que authentik deriva los slugs de los nombres y convirtióLiteLLMenlite-llm; el slug acaba en la URL del emisor, así que merece la pena ponerlo a mano siempre. - Provider —
agent-gateway - Hidden from Application Dashboard — márcalo. Ninguna persona debería ver nunca una tarjeta de un cliente máquina.
Repite la petición de token y funciona. No cambió nada salvo el emparejamiento.
Paso 3 — Un scope que significa algo
El token que obtienes ahora lleva "scope": "". Demuestra quién llama y no concede ninguna
autoridad. Un gateway que lo reciba sabe que el cliente es agent-gateway y nada más.
Customization → Property Mappings → New Property Mapping → Scope Mapping.
- Mapping Name —
gateway-invoke, la etiqueta del objeto dentro de authentik - Scope name —
gateway:invoke, la cadena que piden los clientes y que aparece en el token - Expression —
return {}

Los dos campos de nombre son cosas distintas y el formulario no lo deja claro. La expresión es Python cuyo valor de retorno se fusiona con los claims del token; un diccionario vacío es lo correcto aquí, porque queremos el scope nombrado en el token, no datos extra dentro de él.
Adjúntalo: Applications → Providers → agent-gateway → Edit → Advanced protocol settings →
Scopes, y mueve gateway-invoke de Available a Selected.

Lee el texto de ayuda bajo ese panel, porque es todo el mecanismo:
"Select which scopes can be used by the client. The client still has to specify the scope to access the data."
Adjuntar un scope lo hace disponible. El cliente todavía tiene que pedirlo. Por eso el primer token volvió vacío aunque ya había tres scopes adjuntos — nadie los había pedido.
La lista doble marca entradas para borrado cuando las pulsas en la columna derecha, y la única señal es una línea de texto pequeño encima que dice "4 items selected. 1 item marked to remove." Guardar en ese momento desvincula en silencio el scope que acabas de añadir. Lee esa línea antes de guardar.
Ahora pídelo:
TOKEN=$(~/agent-auth/token.sh gateway:invoke)
jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson' <<< "$TOKEN"
{
"iss": "http://10.80.4.212:9100/application/o/agent-gateway/",
"sub": "2451c6a1bab3085590263f119e650448beb013336146bcfee8241dd5832c200b",
"aud": "aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ",
"exp": 1788901322,
"iat": 1788901022,
"acr": "goauthentik.io/providers/oauth2/default",
"jti": "NRDTukijwLJbW7gpPtn9zUZwzTj6d2kBSZCuFYvX",
"azp": "aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ",
"uid": "neugHWoWQOQzADgbV1vKwzew2COQ1RgCXfyywgqa",
"scope": "gateway:invoke"
}

exp menos iat son 300 segundos. Ese es el argumento entero a favor de esto frente a una
clave estática: un token pegado en un chat, en un fichero de log o en una captura no vale nada
cinco minutos después.
Ahí no hay email ni preferred_username, porque no hay usuario. sub es una cuenta de
servicio que authentik creó por ti, llamada ak-agent-gateway-client_credentials — aparece en
Directory → Users sin que nadie la pidiera.
Paso 4 — Enseñarle al gateway a verificar
LiteLLM documenta la autenticación con JWT con un bloque litellm_jwtauth,
enforce_scope_based_access y scope_mappings. Configúralo en una instalación estándar y no
pasa nada, porque esa misma página dice:
"JWT-based Auth requires a LiteLLM Enterprise license."
Compruébalo antes de escribir configuración:
docker exec llm-gateway python -c "
from litellm.proxy.proxy_server import premium_user
print('premium_user =', premium_user)"
premium_user = False
False significa que todo ese camino documentado está cerrado para ti. La alternativa de
código abierto es custom_auth, que le pasa cada petición a una función que escribes tú — y
para este trabajo son unas veinticinco líneas.
Con custom_auth puesto, todas las peticiones pasan por tu función, incluidas las claves
virtuales sk- del resto de la serie del gateway y la propia master key. Un handler que
solo entienda JWT te deja fuera de tu propio gateway. El de abajo cae de vuelta a la master
key a propósito.
Haz copia de seguridad antes de empezar: cp config.yaml config.yaml.bak
import os
import jwt
from fastapi import Request
from litellm.proxy._types import UserAPIKeyAuth
JWKS_URL = os.environ["JWT_PUBLIC_KEY_URL"]
ISSUER = os.environ["JWT_ISSUER"]
AUDIENCE = os.environ["JWT_AUDIENCE"]
REQUIRED_SCOPE = "gateway:invoke"
MASTER_KEY = os.environ["LITELLM_MASTER_KEY"]
_jwks = jwt.PyJWKClient(JWKS_URL)
async def user_api_key_auth(request: Request, api_key: str) -> UserAPIKeyAuth:
token = api_key.removeprefix("Bearer ").strip()
# No es un JWT: cae a la master key para que las herramientas existentes sigan funcionando.
if token.count(".") != 2:
if token == MASTER_KEY:
return UserAPIKeyAuth(api_key=token)
raise Exception("not a JWT and not the master key")
signing_key = _jwks.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=ISSUER,
audience=AUDIENCE,
)
if REQUIRED_SCOPE not in claims.get("scope", "").split():
raise Exception(f"token is missing scope {REQUIRED_SCOPE}")
return UserAPIKeyAuth(api_key=token, user_id=claims["sub"])
PyJWKClient hace la parte que parece difícil. Descarga el JWKS, empareja la cabecera kid
del token con la clave correcta, y la cachea — así que después de la primera petición la
verificación es aritmética local. jwt.decode comprueba la firma, el emisor, la audiencia y la
expiración en una sola llamada, y lanza excepción si alguna falla.
Tres variables de entorno, añadidas a ~/llm-gateway/.env:
JWT_PUBLIC_KEY_URL=http://10.80.4.212:9100/application/o/agent-gateway/jwks/
JWT_ISSUER=http://10.80.4.212:9100/application/o/agent-gateway/
JWT_AUDIENCE=aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ
JWT_ISSUER y JWT_AUDIENCE tienen que coincidir exactamente con el iss y el aud que
viste en el token decodificado, o se rechazan todos los tokens.
Monta el módulo igual que el gateway ya monta scrubber.py, en docker-compose.yml:
- ./jwt_auth.py:/app/jwt_auth.py:ro
Y en config.yaml, bajo general_settings:
custom_auth: jwt_auth.user_api_key_auth
custom_auth_run_common_checks: true
Esa segunda línea importa más de lo que parece. Sin ella, LiteLLM avisa al arrancar:

"custom_auth is configured but 'custom_auth_run_common_checks' is not set. Problem: budgets, model-access allowlists, and per-model rate limits configured on your DB team/project records will NOT be enforced for custom-auth requests."
Léelo con calma. Añadir autenticación por token habría dejado, por defecto, a todos los portadores de un token exentos de los controles que ya tenías configurados. Habrías hecho el gateway más seguro en una dimensión y más débil en otra sin enterarte, y el único aviso es una línea al arrancar.
config.yaml es un bind mount. Cambiar su contenido no cambia ninguna parte de la
especificación de compose, así que docker compose up -d informa
Container llm-gateway Running y no hace nada. Tus pruebas corren entonces contra el proceso
viejo y reproducen los resultados viejos exactamente, lo que parece que tu cambio no tuvo
efecto.
Usa docker restart llm-gateway, y confírmalo viendo desaparecer el aviso de arriba del log.
Paso 5 — El límite, en tres códigos de estado
TOKEN=$(~/agent-auth/token.sh gateway:invoke)
NOSCOPE=$(~/agent-auth/token.sh)
curl -sS -o /dev/null -w 'no token HTTP %{http_code}\n' http://127.0.0.1:4000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'
curl -sS -o /dev/null -w 'wrong scope HTTP %{http_code}\n' http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $NOSCOPE" -H 'Content-Type: application/json' \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'
curl -sS -o /dev/null -w 'valid token HTTP %{http_code}\n' http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'
no token HTTP 401
wrong scope HTTP 401
valid token HTTP 429

Las dos primeras no llegan a ningún modelo. La segunda es la que importa: un token válido,
correctamente firmado, sin expirar y del emisor correcto, rechazado porque no llevaba
gateway:invoke. La identidad por sí sola no es autoridad.
La tercera es la interesante, y no es un 200. Pasó la autenticación y el scope, y la respondió una parte completamente distinta del gateway — la capa de presupuesto y enrutado que configura la serie del gateway, que en esta máquina ya tenía un tope alcanzado. Ese es el sentido de la forma, no un fallo en ella: un 401 significa no sabemos quién eres, o no puedes hacer esto, y cualquier otra cosa significa que la petición pasó la identidad por completo y ahora es decisión de otro. El trabajo de la autenticación termina en las dos primeras líneas.
Confirma que el rechazo vino de tu comprobación y no de otra cosa:
docker logs llm-gateway --since 10m 2>&1 | grep -iE 'missing scope|401 Unauthorized' | tail -5
user_api_key_auth(): Exception occured - token is missing scope gateway:invoke
Exception: token is missing scope gateway:invoke
INFO: "POST /v1/chat/completions HTTP/1.1" 401 Unauthorized

Fíjate en dónde vive esa razón. Quien llama recibe un 401 pelado sin explicación, y está bien — decirle a un cliente "tu token es válido pero le falta el scope X" le dice a un atacante exactamente qué tiene que ir a buscar. Eso sí significa que depurar esto pasa siempre por leer el log del gateway, y nunca por el cuerpo de la respuesta.
Puedes darle a un agente headless su propia identidad: un cliente confidencial restringido a un solo flujo, un scope que significa algo, y un gateway que verifica la firma localmente y rechaza cualquier cosa sin el scope — con el rechazo demostrado desde el log en vez de supuesto desde un código de estado.
Resolución de problemas — los errores que dio esta ejecución
invalid_client
Las credenciales no autenticaron en absoluto. Antes de culpar a authentik, mira qué acabó de
verdad en tu .env:
awk -F= '{printf "%-18s %3d chars\n", $1, length($2)}' ~/agent-auth/.env
AK_TOKEN_URL 48 chars
AK_CLIENT_ID 40 chars
AK_CLIENT_SECRET 128 chars
El client ID tiene 40 caracteres y el secreto 128. Un valor de longitud cero significa que el problema es el fichero, no el proveedor.
invalid_grant, con todo aparentemente correcto
El proveedor no está emparejado con una aplicación. Mira el Paso 2 — la lista de Providers lo señala, el texto del error no.
AK_TOKEN_URL: unbound variable, y un secreto corrupto
echo 'X=1' >> .env sobre un fichero cuya última línea no termina en salto de línea añade a
esa línea en vez de crear una nueva:
AK_CLIENT_SECRET=p94oDU...dgJcAK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/
Dos variables destruidas con un solo comando, en silencio — el secreto ahora está mal y la
variable nueva no existe. Compruébalo con cat -A, que marca los finales de línea con $, y
prefiere escribir el fichero entero con un solo heredoc antes que ir añadiendo.
El cambio de configuración no tuvo efecto
El contenedor nunca se reinició. Mira el aviso del Paso 4.
401 sin ninguna razón en la respuesta
Es a propósito. La razón está en docker logs llm-gateway.
Qué te dio esto y qué no
Hecho: una identidad de máquina emitida por tu proveedor de identidad en vez de acuñada a mano, una credencial que expira en cinco minutos, un scope que hay que pedir y que se comprueba en cada llamada, revocación en un solo sitio, y alguien a quien el gateway puede nombrar en sus logs como algo más que un prefijo de clave.
Sin hacer:
- El servidor MCP sigue abierto. Todo esto protege el gateway. El servidor de herramientas
de la Parte 4 de orquestación de agentes todavía se
fía de cualquier cosa que alcance su puerto,
issue_refundincluido. El mismo problema, una capa más arriba, y el tema de la próxima parte. - Un scope, un cliente. Un despliegue real tiene varios agentes con scopes distintos, y
scope_mappingsentre scopes y modelos es a dónde va eso. - La master key sigue funcionando, a propósito, como fallback en el handler. Sigue siendo la credencial de emergencia que describió la Parte 4, y sigue saltándose todo.
- Sigue siendo HTTP plano. El token cruza la red en claro, y un token en claro es una clave en claro hasta que expira.
- La cuenta de servicio es invisible. authentik creó
ak-agent-gateway-client_credentialssin preguntar, y nada en el token dice que sea una máquina y no una persona.
Qué viene después
El servidor MCP. FastMCP trae un JWTVerifier que acepta un jwks_uri, un issuer, una
audience y required_scopes — las mismas cuatro cosas configuradas aquí — así que el
servidor de herramientas puede exigir un token con scope antes de listar una herramienta, no
digamos ejecutarla. Eso cierra el hueco que dejó abierto la Parte 4 de orquestación de agentes,
donde el recorte pasaba en el cliente y el servidor se fiaba de quien alcanzara el puerto.
