← All posts

Un BFF para muchos inquilinos, y la única petición que llega sin cookie

Authagonal·July 28, 2026

A las aplicaciones de página única se les enseñó a guardar sus propios tokens. La aplicación ejecuta el flujo de OAuth en el navegador, recibe un token de acceso y normalmente también un token de actualización, los guarda en algún lugar de JavaScript y los adjunta a cada llamada a la API. Está bien documentado, es lo que muestran la mayoría de los tutoriales, y coloca tu credencial más longeva en el único sitio que no puedes defender: un entorno de ejecución que ejecuta de buena gana cualquier cosa que consiga llegar a la página. Una inyección exitosa, una dependencia comprometida en algún punto de tu build, y ya nadie necesita hacer phishing a nadie. Leen el token y se marchan, y sigue funcionando hasta que caduca.

El patrón backend-for-frontend pone la credencial fuera de alcance. El navegador habla con un pequeño servidor que pertenece a tu aplicación, y ese servidor es el cliente confidencial de OAuth. Ejecuta el intercambio de código, guarda los tokens de acceso y de actualización, y no entrega al navegador más que una cookie opaca. Esto es una descripción de cómo construimos el nuestro, y de la única parte que resultó ser genuinamente interesante: hacer que sirviera a más de un inquilino.

Con qué se queda el navegador

Una cookie llamada __Host-agbff, marcada como HttpOnly, SameSite=Lax, con alcance a toda la ruta y sin caducidad, de modo que muere con la sesión del navegador. Su valor son 256 bits de aleatoriedad y nada más. No es un token, no se decodifica en nada, y robarla del cable no es algo que puedas hacerle a una cookie __Host- sobre TLS.

Todo lo real reside en el lado del servidor, en un registro de sesión dentro de una caché distribuida: el token de acceso, el token de actualización, el token de identidad, cuándo caduca el token de acceso y a qué inquilino pertenece la sesión. El inicio de sesión en sí es un flujo de código de autorización corriente con PKCE, ejecutado por un cliente confidencial que se autentica ante el endpoint de tokens con su secreto. El token de identidad se valida como es debido a la vuelta: emisor, audiencia, firma contra las claves publicadas, tiempo de vida, y luego una comparación en tiempo constante del nonce con el valor guardado antes de la redirección. Solo después de todo eso existe una sesión y se establece una cookie.

Una cabecera de la que se comprueba su existencia, y nada más

El navegador llama al BFF para obtener su propia información de usuario y para alcanzar la API que hace de proxy, y esas llamadas llevan una cabecera personalizada, x-authagonal-bff. Su valor es irrelevante. Su presencia es toda la comprobación.

Parece perezoso pero no lo es. Toda la clase de falsificación de petición entre sitios se apoya en el envío de un formulario, una etiqueta de imagen o una navegación que otro origen puede desencadenar mientras tu cookie va de acompañante, y ninguna de esas cosas puede establecer una cabecera personalizada. En el momento en que un JavaScript atacante intenta añadir una, deja de ser una petición simple y pasa a ser una con verificación previa, que tu política de CORS rechaza. La cabecera no es un secreto que haya que adivinar, es la prueba de que la petición vino de código y no de marcado.

Se exige en las llamadas hechas por script y deliberadamente no en las que por naturaleza son navegaciones de nivel superior: iniciar un inicio de sesión, volver del proveedor de identidad, seguir un enlace de cierre de sesión. Exigir una cabecera personalizada en una navegación del navegador no haría más que romper la navegación.

Actualizar, exactamente una vez

Cada petición que pasa por el proxy puede descubrir que el token de acceso está a punto de caducar, y una página con mucha actividad hace varias peticiones a la vez. Actualízalas todas de forma ingenua y obtienes un pequeño desastre: varias actualizaciones simultáneas con un token de actualización rotatorio, cada una invalidando a las demás, terminando con el usuario desconectado por su propio tráfico.

Por eso la actualización es de vuelo único, por sesión. La primera petición que pasa toma una compuerta indexada por el id de sesión, y todas las demás esperan. La parte sutil es lo que hacen las que esperan cuando por fin entran: releen la sesión desde el almacén antes de decidir nada, porque la petición que sostuvo la compuerta primero muy probablemente ya ha actualizado, y el valor que sostenían al ponerse en cola está obsoleto. Actualiza una vez, y luego todos usan el resultado.

También es consciente de la rotación, en el sentido aburrido y necesario. Si el endpoint de tokens devuelve un nuevo token de actualización, este reemplaza al almacenado. Si la actualización falla con un error de token, la sesión se elimina y el usuario queda desconectado, porque un token de actualización que ha sido rechazado no va a ser aceptado en el siguiente intento, y reintentar no es más que una manera más lenta de quedar desconectado.

Un BFF, muchos inquilinos

Un BFF de un solo inquilino lo resuelve todo a partir de la configuración al arrancar: una autoridad, un id de cliente, un secreto. Servir a muchos inquilinos desde un mismo despliegue rompe eso, porque ahora el BFF tiene que averiguar a qué emisor pertenece un inicio de sesión, y tiene que hacerlo antes de que exista sesión alguna.

La selección ocurre en el endpoint de inicio de sesión, mediante un parámetro de consulta que tú decides cómo llamar, así que puede ser ?slug= o ?org= o como ya llame tu producto a un cliente. Un resolvedor convierte esa clave en una configuración de inquilino: autoridad, id de cliente, secreto, ámbitos. Si no resuelve a nada, el inicio de sesión se rechaza en el acto.

Luego la clave tiene que sobrevivir a un viaje de ida y vuelta hasta un proveedor de identidad que no sabe nada de ella, y regresar. Todavía no hay sesión que la conserve, así que viaja en la cookie de correlación, esa cookie cifrada de corta vida que ya lleva, por exactamente esta razón, el verificador PKCE, el estado y el nonce. Quince minutos, una por intento de inicio de sesión. A la vuelta la cookie se descifra, el inquilino se resuelve de nuevo a partir de la clave que contiene, y el código se intercambia contra el endpoint de tokens de ese inquilino. Solo entonces la clave de inquilino se escribe en la sesión, donde se convierte en la respuesta duradera.

A partir de ahí, cada ruta vuelve a resolver desde la sesión en vez de desde cualquier cosa que dijera el navegador: actualización, cierre de sesión, el proxy. Y cuando un inquilino deja de resolver, porque fue eliminado o deshabilitado, la sesión se destruye en lugar de recurrir en silencio a algún inquilino por defecto. Un inquilino irresoluble es un cierre de sesión, no un encogimiento de hombros.

El cierre de sesión back-channel es donde este diseño se gana el sueldo. El proveedor de identidad envía por POST un token de cierre de sesión directamente a tu servidor cuando una sesión termina en otro sitio. Sin cookie. Sin sesión. Sin navegador de por medio en absoluto. En un BFF de un solo inquilino esto no tiene nada de particular, ya que solo hay un emisor del que podría provenir. En uno multiinquilino tienes que responder «¿para qué inquilino es esto?» antes de poder responder «¿es siquiera auténtico?», y el único material disponible es el propio token, que no has verificado y por tanto no puedes confiar en él.

El orden que hace esto seguro merece enunciarse con precisión. Lee el claim de emisor del token no verificado y úsalo para exactamente una cosa: elegir qué configuración de inquilino cargar. Luego verifica la firma del token contra las claves publicadas de ese inquilino y su id de cliente como audiencia. Un emisor falsificado elige el inquilino que finge ser, y luego falla la verificación contra las claves de ese inquilino, porque el atacante no las tiene. Nunca se aceptó nada sobre la base del claim no confiable. El claim solo decidió quién puede juzgarlo, y el juez es un ancla de confianza que ya teníamos.

Después vienen las comprobaciones que hacen de un token de cierre de sesión un verdadero token de cierre de sesión: no debe llevar nonce, ya que no está autenticando a nadie; debe llevar el evento de cierre de sesión back-channel; y debe identificar o bien una sesión concreta, o bien un sujeto. Un id de sesión mata una sesión. Un sujeto mata todas las sesiones que tiene ese usuario, que es la forma que emitimos, porque «ciérrame la sesión en todas partes» es lo que la gente realmente quiere decir cuando pulsa cerrar sesión en todas partes. Por eso el almacén de sesiones mantiene un índice por id de sesión y otro por sujeto, en vez de solo por valor de cookie.

Un solo inquilino sigue siendo aburrido

Todo esto permanece inerte en el caso común. El resolvedor por defecto devuelve la misma configuración sin importar qué clave se le entregue, y su resolución por emisor devuelve esa configuración sin condiciones, porque solo hay una. Un BFF de un solo inquilino se comporta exactamente igual que antes de que la costura existiera: ningún parámetro de inquilino, nada extra en la cookie, ningún modo de fallo nuevo. La maquinaria multiinquilino es opcional e invisible hasta que nombras un parámetro de consulta.

Separa la selección de la confianza

Cuando conviertes algo en multiinquilino, el ejercicio que vale la pena hacer es enumerar cada punto de entrada y preguntar qué identifica al inquilino en cada uno. Las navegaciones del navegador llevan una cookie que tú emitiste y controlas. Las devoluciones de llamada de redirección llevan un estado que tú firmaste. Las devoluciones de llamada de servidor a servidor solo llevan lo que quien llama decidió enviar, y quien llama podría estar mintiendo.

El punto de entrada incómodo, el que no lleva nada digno de confianza, es aquel cuyo diseño decide si todo el sistema es sólido, y suele ser el que nadie tiene en cuenta hasta tarde. El patrón que nos sacó adelante se generaliza: separa la selección de la confianza. No hay problema en enrutar según datos no confiables mientras el enrutamiento solo elija qué ancla de confianza tiene permitido tomar la decisión final, y la decisión final la tome algo que el atacante no puede falsificar.

Si prefieres no escribir todo eso por tu cuenta, Authagonal incluye el BFF para .NET y para Node, y el portal aprovisionará el cliente confidencial que necesita, URI de redirección, endpoint de cierre de sesión back-channel y todo lo demás, en un solo clic.