Un solo inicio de sesión para todo: poniendo authentik delante de una app autoalojada
- 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 1 cerró los puertos que nadie quiso abrir. La Parte 2 impidió que dos contenedores se ejecutaran como root. Ambas trataban sobre la máquina. Ninguna tocó la pregunta que una pila de IA autoalojada responde peor: ¿quién tiene permiso para iniciar sesión, y dónde se decide eso?
La misma caja de antes. El Langfuse que venimos endureciendo desde la Parte 1 — aquel cuyo Postgres, ClickHouse y Redis la Parte 1 encontró correctamente enlazados a localhost, y cuyo worker la Parte 2 bajó de root — se desplegó en Detecta lo que tus pruebas no ven. Dos partes han asegurado ya la máquina por debajo sin tocar ni una vez la puerta de entrada de la propia aplicación. Esta es la primera parte que cambia Langfuse en sí.
Ahora mismo esa decisión se toma en cada app, por separado. Langfuse tiene su propia tabla de correo y contraseña. También la tiene cualquier otra herramienta en la caja. Cada una es un lugar donde una cuenta puede sobrevivir a la persona que la tenía, donde una contraseña puede reutilizarse, y donde "quitarle el acceso a esta persona" significa acordarse de que esa app existe.
Este post mueve esa decisión a un solo lugar. Ese lugar es authentik — un proveedor de identidad de código abierto que ejecutas tú mismo, la contraparte autoalojada de Okta o Auth0. Guarda las cuentas, muestra la pantalla de inicio de sesión, y da fe de quién es alguien ante cualquier app que pregunte. Las apps dejan de almacenar contraseñas y empiezan a preguntarle a authentik.
Es el mismo trabajo que hace Keycloak, y Keycloak es el nombre más conocido. authentik se gana la elección aquí por el coste de puesta en marcha: un archivo compose y un asistente frente a los realms, clients y ajustes de JVM de Keycloak. Para una caja y una app, esa diferencia es toda la decisión.
Lo desplegamos, conectamos Langfuse a él por OIDC, y terminamos con un inicio de sesión que pasa por authentik y vuelve.
Una WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15.
authentik 2026.8.1 desde ghcr.io/goauthentik/server, Postgres 16. Langfuse v3
(langfuse/langfuse:3) — el despliegue heredado de las Partes 1 y 2, accesible en
10.80.4.212:3001. authentik publicado en el puerto host 9100.
Las direcciones de este post son una LAN privada. Sustituye las tuyas. Todo aquí es HTTP plano sobre una red de confianza, lo cual está bien para una primera ejecución y no lo está para nada accesible desde fuera — mira Qué sigue.
El vocabulario, antes de los clics
Toda guía de OIDC asume que ya conoces estas cinco palabras. Tenerlas claras de entrada es la diferencia entre configurar esto una vez y adivinar campos durante una hora.
OP y RP. El OpenID Provider es lo que sabe quién es la gente — authentik. La
Relying Party es la app que quiere que se lo digan — Langfuse. La RP nunca ve una
contraseña. Recibe una declaración firmada del OP que dice "este es admin@example.com, yo
lo verifiqué".
Proveedor vs aplicación. authentik separa lo que la mayoría de herramientas fusiona. Un proveedor es el endpoint del protocolo — la maquinaria OIDC, el client ID y el secreto, la URI de redirección. Una aplicación es lo que los usuarios ven y aquello a lo que se enganchan las reglas de acceso. Se emparejan uno a uno: una aplicación, un proveedor, y el asistente del Paso 3 crea ambos juntos.
La URI de redirección es a dónde authentik tiene permiso de enviar al usuario tras un inicio de sesión exitoso. Se compara de forma exacta y es la causa de fallo más común. Ni un prefijo, ni un nombre de host — la URL completa, carácter por carácter.
Scopes vs grant types. Un scope es qué información pide la app
(openid email profile). Un grant type es el mecanismo con el que la pide
(authorization_code). Preguntas distintas; la interfaz las pone cerca y es fácil
confundirlas.
Qué hace realmente el inicio de sesión
Lo que vale la pena notar: el código viaja por el navegador, el secreto nunca. Ese intercambio del penúltimo paso ocurre entre los dos contenedores directamente, que es por lo que el secreto del cliente es un ajuste de lado servidor y por lo que Langfuse debe poder alcanzar a authentik por la red — no solo tu navegador.
Requisitos previos
- Un Langfuse en ejecución, idealmente el de la Parte 1
- Docker con el plugin Compose
- Un puerto host libre para authentik (usamos
9100) - La IP LAN de la caja, no
localhost— dos contenedores necesitan alcanzarse entre sí y tu navegador necesita alcanzar a ambos
Paso 1 — Desplegar authentik
authentik trae un archivo compose y genera sus propios secretos. Crea un directorio y descarga ambos:
mkdir -p ~/authentik && cd ~/authentik
curl -O https://goauthentik.io/docker-compose.yml
Ahora el archivo de entorno. authentik necesita una contraseña de Postgres y una clave secreta, ambas aleatorias, más los puertos que publicará:
cd ~/authentik
{
echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')"
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')"
echo "COMPOSE_PORT_HTTP=9100"
echo "COMPOSE_PORT_HTTPS=9543"
} > .env
chmod 600 .env
AUTHENTIK_SECRET_KEY firma sesiones y tokens. Si la pierdes, toda sesión existente y todo
token emitido quedan inválidos — así que pertenece a tu gestor de contraseñas, no solo a
este archivo.
Levántalo. El primer arranque ejecuta migraciones de base de datos y tarda un minuto o dos:
cd ~/authentik && docker compose up -d
cd ~/authentik && docker compose ps
Tres contenedores — server, worker y postgresql — todos reportando (healthy). Los
archivos compose más antiguos de authentik también traían un servicio redis aparte;
2026.8.1 ya no lo hace, así que tres es correcto y no falta nada. server es el que tiene
que estar saludable antes de que continúes.

Paso 2 — Reclamar la cuenta de administrador
El primer arranque de authentik deja la cuenta de administrador sin reclamar. La URL de configuración existe exactamente una vez; visitarla es cómo tomas posesión.
Abre http://10.80.4.212:9100/if/flow/initial-setup/, y establece el correo y la contraseña
del administrador por defecto (akadmin).

Usa una dirección que controles. Esta cuenta puede hacer de todo, así que dale una contraseña de verdad y guárdala en algún lugar duradero.
Hasta que alguien complete este formulario, cualquiera que pueda alcanzar el puerto puede convertirse en administrador de tu proveedor de identidad. Hazlo inmediatamente después de que los contenedores levanten — no mañana.
Aterrizas en el Application Dashboard — la vista de cara al usuario, no la de administración, y vacía porque no hay nada configurado todavía.

Create a new application aquí lleva al mismo sitio que la interfaz de administración; el
botón Admin interface arriba a la derecha es cómo llegas a todo lo demás.
Paso 3 — Ejecutar el asistente de aplicación
Applications → Applications → Create. authentik te lleva por cinco pasos, y vale la pena leer la lista de pasos de la izquierda antes de tocar nada:
Application → Choose a Provider → Configure Provider → Configure Bindings → Review and Submit
Ese es el vocabulario de antes, en orden. El asistente crea la aplicación y su proveedor en
una sola pasada y los empareja por ti. (Applications → Providers → Create construye un
proveedor por su cuenta, si alguna vez necesitas uno antes que la app que lo usa.)
3.1 — Application
- Application Name —
Langfuse. La etiqueta que los usuarios ven en el dashboard. - Slug —
langfuse. Carga peso: pasa a formar parte de la URL del emisor, así que cambiarlo después cambia la URL con la que tienes que reconfigurar Langfuse. - Group — déjalo vacío. Esto agrupa apps en el dashboard; no tiene nada que ver con grupos de usuarios ni control de acceso, pese al nombre.
- Policy engine mode — déjalo en ANY. Decide cómo se combinan múltiples políticas vinculadas. Sin políticas vinculadas, todavía no supone diferencia.

3.2 — Choose a Provider
Ocho tipos de proveedor. Toma OAuth2/OpenID Provider — "OAuth2 Provider for generic OAuth and OpenID Connect Applications."

Vale la pena saber qué no estás eligiendo, porque dos de estos resuelven un problema distinto: Proxy Provider pone a authentik delante de una app que no soporta SSO por sí misma, y LDAP Provider expone authentik a cosas que solo hablan LDAP. Langfuse habla OIDC de forma nativa, así que recibe un proveedor OIDC de verdad.
3.3 — Configure Provider
El paso que importa, y el que te costará tiempo si lo apuras.
-
Authorization flow —
default-provider-authorization-explicit-consent. "Explicit" muestra al usuario una pantalla de consentimiento que lista lo que Langfuse pidió. La variante implícita se la salta. Elige explícito para una primera construcción: esa pantalla es una lectura en vivo de tu configuración de scopes, lo que hace visible una mala configuración en lugar de silenciosa. -
Client type — Confidential (desplazado por encima de los campos de redirección). Langfuse es un servidor y puede guardar un secreto, así que recibe uno. Los clientes públicos son para navegadores y apps móviles que no pueden.
-
Redirect URIs/Origins (RegEx) — el campo con dos desplegables y un valor. Pon el modo en Strict, el propósito en Authorization, y la URI en el callback de NextAuth de Langfuse:
http://10.80.4.212:3001/api/auth/callback/customEl
customfinal no es un marcador de posición — es el ID de proveedor que NextAuth asigna a su proveedor OIDC genérico. Usa tu propio host y puerto, pero esa ruta es fija. -
Signing Key — deja
authentik Self-signed Certificate. Esto firma el token de ID; Langfuse obtiene la clave pública correspondiente deljwks_urien el Paso 5 y verifica contra ella. Autofirmado es lo correcto aquí — la RP confía en esta clave porque el descubrimiento se la entregó, no porque una CA responda por ella. -
Logout URI y las validez de los tokens — déjalos en paz.
El proveedor una vez guardado — flujo de autorización con consentimiento explícito, tipo de cliente Confidential, y Authorization Code como grant:

El desplegable de modo también ofrece Regex, y el propio texto de ayuda de authentik
señala que puedes ponerlo en .* para permitir cualquier URI de redirección. No lo hagas.
Una URI de redirección abierta significa que cualquiera que pueda iniciar un login puede
hacer que el código de autorización se entregue a un host que él controla. Strict, con una
URL exacta, es el sentido entero del campo.
Más abajo, Scopes viene precargado con cuatro mapeos: email, openid, profile y
offline_access. Déjalos. openid es obligatorio y es lo que marca esto como OIDC en lugar
de OAuth2 a secas. email es el claim con el que Langfuse identifica la cuenta. profile
lleva el nombre visible. offline_access es el que el asistente añade sin preguntar —
permite un token de refresco, de modo que una sesión puede renovarse sin mandar al usuario de
vuelta por el flujo de login.
3.4 — Configure Bindings
No bound policies. — y lo vamos a dejar así.

Lee la descripción del propio asistente: "These policies control which users can access this application." Sin nada vinculado, la respuesta es todo el que pueda autenticarse. En una caja donde eres la única cuenta, eso es lo mismo que "solo yo" — que es por lo que resulta tolerable aquí y por lo que el asistente te deja saltártelo.
No es control de acceso. Toda cuenta que crees en este authentik entra en Langfuse por defecto, y nada te avisará. Los grupos y las vinculaciones de políticas son cómo se arregla eso, y son el tema del siguiente post — donde lo que se protege es un gateway y la respuesta importa muchísimo más.
3.5 — Review and Submit
Revisa el slug y la URI de redirección una vez más, y envía.
Paso 4 — Leer la configuración de vuelta desde authentik
En lugar de ensamblar URLs a mano, pregúntale a authentik. Todo proveedor OIDC publica un documento de descubrimiento, y el de authentik vive bajo el slug de la aplicación:
curl -s http://10.80.4.212:9100/application/o/langfuse/.well-known/openid-configuration \
| jq '{issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, grant_types_supported}'
{
"issuer": "http://10.80.4.212:9100/application/o/langfuse/",
"authorization_endpoint": "http://10.80.4.212:9100/application/o/authorize/",
"token_endpoint": "http://10.80.4.212:9100/application/o/token/",
"userinfo_endpoint": "http://10.80.4.212:9100/application/o/userinfo/",
"jwks_uri": "http://10.80.4.212:9100/application/o/langfuse/jwks/",
"grant_types_supported": [
"authorization_code",
"refresh_token",
"implicit",
"client_credentials",
"password",
"urn:ietf:params:oauth:grant-type:device_code"
]
}
Dos cosas que sacar de esto. El issuer es la única URL que Langfuse necesita — él mismo
obtendrá este documento y descubrirá el resto. Y grant_types_supported lista lo que el
protocolo ofrece, no lo que tu proveedor aceptará; nosotros usamos authorization_code y la
presencia de password en esa lista no es una invitación.

Ahora las credenciales del cliente. Están en la página de detalle del proveedor en la
interfaz, pero docker compose exec es más rápido y se copia y pega limpio:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
p = Application.objects.get(slug='langfuse').get_provider()
print('PROVIDER :', p.name)
print('CLIENT_ID :', p.client_id)
print('CLIENT_SECRET:', p.client_secret)
"
Fíjate en que esto busca el proveedor a través del slug de la aplicación y no por el nombre
del proveedor. El asistente nombró a tu proveedor Provider for Langfuse, no langfuse — el
slug es lo que tú elegiste y lo que se mantiene predecible. get_provider() también importa:
recurrir a application.provider devuelve la clase base con client_id vacío, lo que se
parece alarmantemente a un proveedor roto y no lo es.
Mantén esa salida fuera del historial de tu shell y fuera de las capturas de pantalla.
La página de detalle del proveedor lleva los mismos valores, por si prefieres leerlos ahí — tipo de cliente, client ID, la URI de redirección estricta, y cada URL de descubrimiento en un solo sitio. El secreto del cliente es lo único que no imprime:

Paso 5 — Apuntar Langfuse hacia él
Langfuse lee ajustes OIDC genéricos desde variables AUTH_CUSTOM_*. Añádelas a
~/langfuse/.env:
AUTH_CUSTOM_NAME=authentik
AUTH_CUSTOM_ISSUER=http://10.80.4.212:9100/application/o/langfuse
AUTH_CUSTOM_CLIENT_ID=<el client id del Paso 4>
AUTH_CUSTOM_CLIENT_SECRET=<el client secret del Paso 4>
AUTH_CUSTOM_SCOPE=openid email profile
El issuer aquí no lleva barra final mientras que el documento de descubrimiento reportó una. Ambos funcionan — Langfuse lo normaliza.
Confirma también que NEXTAUTH_URL coincide con la dirección que realmente navegas:
grep NEXTAUTH_URL ~/langfuse/.env
NEXTAUTH_URL=http://10.80.4.212:3001
Si esto dice localhost mientras tu URI de redirección dice 10.80.4.212, el callback se
construirá con el nombre de host equivocado y authentik lo rechazará.
Ahora la parte que te hará perder la tarde si te la saltas. Escribir esas variables en
.env no basta. El archivo compose que trae Langfuse lista explícitamente las variables que
pasa al contenedor langfuse-web, y AUTH_CUSTOM_* no está entre ellas — aterrizan en
.env, Compose las lee para interpolación, y nunca llegan al proceso. Langfuse arranca bien
y no muestra ningún botón de SSO, sin nada en los registros que diga por qué.
Pásalas con un archivo de override, ~/langfuse/docker-compose.override.yml:
services:
langfuse-web:
environment:
AUTH_CUSTOM_NAME: ${AUTH_CUSTOM_NAME}
AUTH_CUSTOM_ISSUER: ${AUTH_CUSTOM_ISSUER}
AUTH_CUSTOM_CLIENT_ID: ${AUTH_CUSTOM_CLIENT_ID}
AUTH_CUSTOM_CLIENT_SECRET: ${AUTH_CUSTOM_CLIENT_SECRET}
AUTH_CUSTOM_SCOPE: ${AUTH_CUSTOM_SCOPE}
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: "true"
Compose fusiona docker-compose.override.yml automáticamente, así que el archivo original
queda intacto y sobrevive a las actualizaciones. Esa última variable es el arreglo de un
fallo que si no te encontrarías en el siguiente paso — la sección de resolución de problemas
explica qué hace y qué cuesta.
Recrea el contenedor web y verifica que las variables llegaron:
cd ~/langfuse && docker compose up -d langfuse-web
cd ~/langfuse && docker compose exec langfuse-web env | grep -c AUTH_CUSTOM
Seis. Si es cero, el override no se está recogiendo — comprueba que estás ejecutando compose
desde ~/langfuse y que el nombre del archivo es exactamente docker-compose.override.yml.

Paso 6 — Iniciar sesión
Abre http://10.80.4.212:3001/auth/sign-in, en una ventana privada para no estar mirando una
sesión existente. Hay un botón nuevo bajo el separador debajo del formulario de contraseña.

El botón se llama authentik porque ese es tu valor de AUTH_CUSTOM_NAME, renderizado tal
cual. Ponlo en Company SSO y eso es lo que dirá el botón — es una cadena de visualización y
nada más depende de ella.
Otras dos cosas en esa captura vale la pena nombrarlas en lugar de pasarlas por alto. El formulario de correo y contraseña sigue ahí, y también Sign up — el SSO se ha añadido, no sustituido. Y la barra de direcciones dice Not Secure, correctamente: esto es HTTP plano.
Haz clic en el botón y te entregan a authentik, que te dice de dónde vienes:

Inicia sesión, y como elegimos el flujo de consentimiento explícito en el Paso 3.3, authentik muestra lo que Langfuse pidió antes de entregar nada:

Dice You're about to sign into Langfuse, te identifica como akadmin, y lista dos permisos
— Email address y General Profile Information. Esos son los scopes email y profile
en forma legible. openid no recibe una línea propia: no lleva datos personales que consentir,
es la bandera que hace que esto sea OIDC en lugar de OAuth2 a secas. Así que una configuración
de tres scopes produce una pantalla de consentimiento de dos elementos, lo cual es correcto y
no señal de que algo se haya perdido.
Continúa, y aterrizas en Langfuse, con la sesión iniciada, como la identidad por la que authentik respondió.
Ese es el bucle cerrado. Langfuse nunca vio una contraseña.
Resolución de problemas — los errores que esta ejecución produjo de verdad
OAuthAccountNotLinked
El primer intento de inicio de sesión rebotó de vuelta a la página de login de Langfuse con
?error=OAuthAccountNotLinked en la URL y nada más.
La causa: ya existía una cuenta de Langfuse de correo y contraseña con la misma dirección que authentik estaba presentando ahora. Lo que NextAuth hace por defecto es rechazar la fusión. Ese comportamiento por defecto es protector — si un proveedor de identidad puede afirmar cualquier correo y la app vincula automáticamente solo por el correo, entonces quien controle el proveedor puede apoderarse de una cuenta existente reclamando su dirección.
AUTH_CUSTOM_ALLOW_ACCOUNT_LINKING: "true" le dice a Langfuse que las vincule de todos modos.
Es seguro aquí porque tú eres dueño del único proveedor y controlas quién puede crear
cuentas en él. No sería seguro apuntando a un proveedor donde cualquiera pueda auto-registrar
un correo arbitrario.
La alternativa, si prefieres no habilitarlo: borra la cuenta local preexistente y deja que el SSO cree una nueva.
Desajuste de redirect_uri, o authentik negándose a redirigir
Lee la URL que falla en la barra de direcciones y compárala con la URI de redirección del
proveedor carácter por carácter. En esta ejecución los desajustes que vale la pena nombrar
fueron la ruta final /api/auth/callback/custom (cualquier otro sufijo falla), localhost
frente a la IP LAN, y una barra final suelta. Comparación estricta significa estricta.
Sin botón de SSO, sin errores
Esa es la trampa del entorno de compose del Paso 5. Comprueba que las variables llegaron al contenedor:
cd ~/langfuse && docker compose exec langfuse-web env | grep AUTH_CUSTOM_ISSUER
Salida vacía significa que Langfuse está corriendo sin la configuración que nunca supo que le faltaba.
El descubrimiento falla desde dentro del contenedor
Que tu navegador alcance a authentik no es prueba de que Langfuse pueda. El intercambio de tokens es de contenedor a contenedor:
La imagen de langfuse-web no trae curl, así que usa el runtime de Node que ya está ahí
dentro — fetch viene incorporado:
cd ~/langfuse && docker compose exec langfuse-web node -e \
"fetch('http://10.80.4.212:9100/application/o/langfuse/.well-known/openid-configuration').then(r=>console.log(r.status)).catch(e=>console.log('FAIL',e.message))"
200 es lo que quieres. FAIL con un error de conexión significa que el contenedor no puede
alcanzar authentik en absoluto, que es justo lo que se está probando.

Esta es también la razón por la que el issuer es una IP LAN en lugar de localhost — dentro
del contenedor de Langfuse, localhost es el contenedor de Langfuse.
Puedes ejecutar tu propio proveedor de identidad y poner una app detrás de él por OIDC —
leyendo el issuer, el client ID y el secreto desde authentik en lugar de ensamblar URLs a
mano, y reconociendo los dos fallos que de verdad te detienen: un archivo compose que nunca
pasa las variables, y OAuthAccountNotLinked.
Qué te compró esto y qué no
Hecho: un proveedor de identidad, un lugar donde viven las cuentas, un lugar para revocarlas. Langfuse ya no almacena una contraseña tuya. Y ahora tienes un proveedor al que puedes apuntar la siguiente app en unos cinco minutos.
No hecho, y vale la pena ser claro al respecto:
- Sin control de acceso. Vinculaciones vacías significan que toda cuenta de authentik llega a Langfuse.
- HTTP plano. El token de ID y el secreto del cliente cruzan la red sin cifrar. En una LAN de confianza eso es un punto de partida tolerable; expuesto a cualquier otra cosa no lo es.
- Un administrador, sin vía de recuperación. Si pierdes
akadmin, pierdes el proveedor que hace de fachada de todo lo que apunte a él. - El login antiguo sigue funcionando. El acceso por contraseña no se deshabilitó, así que el SSO es por ahora una puerta adicional, no una de reemplazo.
Qué sigue
La Parte 4 pone identidad delante de algo donde estos huecos dejan de ser tolerables: la gateway LiteLLM de la serie del gateway. Tres cosas que este post dejó deliberadamente en paz:
- El plano de control no es el plano de datos. El SSO protege una interfaz de administración. No protege una API — los llamantes máquina siguen autenticándose con claves. Para un gateway esa distinción es todo el juego, y suponer lo contrario es cómo la gente concluye que ha asegurado algo que no ha asegurado.
- Grupos y vinculaciones de políticas, para que alcanzar la aplicación requiera pertenencia y no meramente tener una cuenta.
- TLS y exposición real — el gateway está enlazado a
127.0.0.1:4000, y hacerlo correctamente accesible significa un proxy inverso, un nombre de host y un certificado.
Los pasos de proveedor y aplicación serán un repaso corto con un enlace de vuelta aquí. El resto es nuevo.
Lecturas adicionales
- Documentación de authentik — proveedor OAuth2/OpenID
- Documentación de authentik — aplicaciones, y vincularles políticas
- Documentación de Langfuse — SSO y las variables
AUTH_CUSTOM_* - NextAuth — vinculación de cuentas, y por qué existe
OAuthAccountNotLinked - OpenID Connect Core — el flujo de código de autorización
