Volver al blog
7 de octubre de 2026Sergei Solod14 min de lectura

Cómo implementar SSO entre dominios raíz independientes

Una arquitectura práctica para unificar la autenticación entre dominios raíz independientes mediante sesiones exclusivas del host, un emisor de autenticación conocido y tokens de transferencia de un solo uso y corta duración, sin obligar a cada visitante a pasar por una redirección de autenticación.

SSO entre dominiosAutenticaciónSeguridad de sesionesSeguridad webArquitectura web

Necesitaba que varios sitios web independientes funcionaran como un único sistema de cuentas sin debilitar el aislamiento normal del navegador. Un usuario podía iniciar sesión en un dominio, abrir más tarde otro dominio directamente, hacer clic en Iniciar sesión y continuar allí sin volver a introducir sus credenciales.

La restricción importante era que se trataba de dominios raíz independientes. Una cookie de sesión creada por un dominio raíz no puede simplemente configurarse para que se aplique a otro dominio raíz no relacionado. El diseño se volvió mucho más fácil de razonar cuando separé dos conceptos que a menudo se mezclan: identidad global y sesión local del navegador.

El modelo resultante es sencillo: el backend reconoce una única identidad de cuenta compartida, cada dominio posee su propia sesión vinculada al host y una transferencia de un solo uso y corta duración permite que un origen de confianza autenticado autorice la creación de una nueva sesión en otro origen de confianza.

Una identidad global no requiere una cookie compartida

La cuenta puede ser global aunque las sesiones del navegador sean locales.

Cada sitio mantiene una cookie de sesión exclusiva del host. Una cookie emitida por un dominio raíz no puede ampliarse a otro dominio raíz no relacionado mediante el atributo Domain. Ese atributo solo puede ampliar el alcance de una cookie dentro de la jerarquía de dominios que el host emisor tiene permitido utilizar.

Para una sesión que debe permanecer vinculada a un único host, una cookie con el prefijo __Host- encaja bien. Conceptualmente:

Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax

Lo importante son los límites:

  • El Sitio A no puede leer la cookie de sesión exclusiva del host del Sitio B.
  • El Sitio B no puede leer la cookie de sesión exclusiva del host del Sitio A.
  • Ambas sesiones pueden seguir resolviéndose a la misma identidad del backend.

Por tanto, el SSO entre dominios no debería intentar mover una cookie de un sitio a otro. Debería transferir una prueba temporal de que un origen de confianza ya ha autenticado al usuario.

El patrón de transferencia de un solo uso

El origen autenticado expone una pequeña operación de transferencia. El navegador la llama únicamente cuando se necesita una transición de autenticación entre sitios.

El backend genera un ticket criptográficamente aleatorio, devuelve el valor sin procesar al navegador una sola vez y almacena únicamente un hash junto con la información mínima necesaria para validar la transferencia:

{
  tokenHash,
  userId,
  sourceOrigin,
  targetSite,
  returnPath,
  expiresAt
}

El ticket debe tener una vida corta y ser válido para un único destino. La solicitud de emisión en el origen ya debe estar autenticada y protegida contra CSRF. El destino solicitado y la ruta de retorno deben validarse antes de emitir nada.

Después, el navegador realiza un POST de nivel superior al destino. El destino consume el ticket, resuelve la identidad compartida del usuario, crea su propia sesión local y redirige el navegador a la ruta prevista.

Conceptualmente:

record = consumeOnce({
  tokenHash: hash(ticket),
  targetSite: currentSite,
  sourceOrigin: requestOrigin,
  notExpired: true
})

if (!record) deny()

user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)

El ticket no es la sesión de destino. Es una autorización de un solo uso para crear esa sesión de destino.

El consumo atómico cierra la ventana de repetición

Una implementación más débil cargaría el ticket, lo validaría, crearía una sesión y eliminaría el ticket después. Eso crea una ventana en la que dos solicitudes pueden observar el mismo registro todavía válido.

El diseño más seguro combina la validación y la invalidación en una sola operación atómica de consumo. Si ningún registro coincide, la autenticación falla. Si se devuelve un registro, ese ticket ya se ha retirado del conjunto utilizable antes de crear la sesión.

Esto también permite que el destino vincule varias condiciones a la misma operación: destino esperado, origen de procedencia esperado, hash del token y expiración.

La validación de Origin debe rechazar por defecto

Vincular el origen aporta otra propiedad útil: un ticket emitido a través de un origen de confianza no debería poder consumirse desde un origen no relacionado.

La comprobación del origen debería formar parte de la propia condición de consumo. Eso significa que una solicitud procedente del origen incorrecto no consume el ticket válido, mientras que el origen legítimo todavía puede completar la transferencia después.

Hay una salvedad importante: no se garantiza que una cabecera Origin contenga una cadena de origen normal en todos los contextos del navegador. Algunas solicitudes pueden llegar sin un valor utilizable y los contextos opacos pueden producir Origin: null.

Mi regla es tratar un Origin ausente u opaco como una comprobación de origen fallida, salvo que la aplicación disponga de un mecanismo alternativo explícito de validación. El valor literal null no debería convertirse en un origen de confianza solo por compatibilidad.

Esta es una decisión de rechazo por defecto. Si un flujo legítimo necesita admitir un contexto sin un origen utilizable, esa excepción debe diseñarse explícitamente en lugar de debilitar silenciosamente la comprobación general.

Mantén la credencial de transferencia fuera de la URL

Prefiero enviar el ticket temporal mediante un POST de formulario de nivel superior en lugar de colocarlo en una cadena de consulta.

Una versión generalizada del lado del cliente es:

const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';

const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;

form.append(input);
document.body.append(form);
form.submit();

El destino acepta el ticket desde el cuerpo POST esperado y rechaza los intentos equivalentes de proporcionarlo mediante la URL.

Esto no vuelve inocua la credencial, pero la mantiene fuera de las URL de navegación normales y reduce la exposición accidental a través del historial de URL, enlaces copiados, referencias y registros orientados a URL.

Los enlaces autenticados entre sitios pueden mejorarse automáticamente

Si el sitio actual ya sabe que el usuario está autenticado, un enlace normal a otro sitio de confianza puede realizar oportunamente la transferencia.

El enlace debe seguir conteniendo un href real. El SSO es una mejora de la navegación normal, no un sustituto.

Un flujo de cliente reducido se ve así:

if (session.status !== 'authenticated') {
  return; // ordinary browser navigation
}

if (!isTrustedTarget(destination)) {
  return;
}

event.preventDefault();

const ticket = await requestHandoff({
  targetSite,
  returnPath: destinationPath
});

postTicketToTarget(ticket);

La versión de producción también debería protegerse contra envíos duplicados, validar el destino contra un registro de sitios de confianza y volver a la navegación normal si no se puede crear la transferencia.

El comportamiento nativo del navegador debería seguir siendo nativo. El clic con el botón central, los clics con teclas modificadoras, las descargas y los enlaces destinados a otro contexto de navegación no deberían convertirse silenciosamente en solicitudes de autenticación.

El botón Iniciar sesión del segundo sitio necesita un emisor conocido

El caso más difícil comienza cuando el usuario abre directamente el Sitio B. El Sitio B no tiene una sesión local, por lo que trata correctamente al visitante como anónimo aunque el navegador todavía conserve una sesión válida en el Sitio A.

El Sitio B no puede inspeccionar la cookie exclusiva del host del Sitio A. Tampoco puede redirigir a un Sitio C arbitrario y esperar que ese sitio descubra sesiones pertenecientes al resto del sistema. Cada dominio raíz no relacionado solo puede observar las cookies que le pertenecen.

Eso significa que un flujo anónimo de Iniciar sesión necesita un emisor conocido: un origen de autenticación canónico o un sitio de confianza seleccionado específicamente que se espera que conserve la sesión de inicio de sesión reutilizable.

Para el caso sencillo Sitio A → Sitio B, la secuencia es:

  1. El usuario inicia sesión en el Sitio A.
  2. Más tarde, el usuario abre directamente el Sitio B.
  3. El Sitio B no tiene sesión, por lo que muestra Iniciar sesión.
  4. El usuario hace clic en Iniciar sesión.
  5. El navegador realiza una navegación de nivel superior al Sitio A, que es el emisor seleccionado.
  6. El Sitio A puede leer su propia cookie de sesión exclusiva del host existente.
  7. Como esa sesión sigue siendo válida, el Sitio A emite inmediatamente una transferencia de un solo uso para el Sitio B.
  8. El navegador envía el ticket mediante POST al Sitio B.
  9. El Sitio B crea su propia sesión local y devuelve al usuario a la página solicitada.

Desde la perspectiva del usuario, se trata de una redirección rápida al Sitio A y de vuelta. No se requiere contraseña porque el Sitio A ya dispone de la sesión que tiene permitido observar.

La limitación es igual de importante: si el usuario ha iniciado sesión únicamente en el Sitio C, redirigir el Sitio B al Sitio A no ayuda a menos que el Sitio A sea un verdadero intermediario central de autenticación con conocimiento independiente de su estado de inicio de sesión. Una base de datos de usuarios compartida no hace que las sesiones del navegador sean compartidas.

Por qué un emisor canónico simplifica el sistema

A medida que aumenta el número de sitios independientes, un emisor canónico elimina la necesidad de descubrir sesiones entre dominios arbitrarios.

Sin uno, un Sitio B anónimo tendría que decidir de algún modo qué otro sitio podría tener actualmente una cookie útil. El Sitio A no puede inspeccionar la cookie del Sitio C, el Sitio C no puede inspeccionar la cookie del Sitio A y el Sitio B no puede inspeccionar ninguna de las dos.

Un emisor canónico proporciona a cada intento anónimo de inicio de sesión una ruta predecible:

Site B
  → authentication issuer
  → issuer session exists?
      yes → create one-time handoff
      no  → show normal login
  → Site B consumes handoff
  → Site B creates local session

El emisor no necesita servir el resto de la aplicación. Su responsabilidad relevante es poseer la sesión de autenticación reutilizable y emitir transferencias con un alcance estrictamente limitado a sitios de confianza.

Para un sistema más pequeño, un sitio existente puede desempeñar ese papel. Para una arquitectura más amplia, un origen de autenticación dedicado puede hacer que el modelo de confianza sea más fácil de entender.

No hagas pasar a todos los visitantes por el emisor

El flujo del emisor no debería ejecutarse automáticamente en cada vista de página anónima.

Redirigir a todos los visitantes a otro dominio solo para comprobar si existe una sesión crea navegación innecesaria, latencia adicional, más modos de fallo, analítica más ruidosa y un comportamiento de caché más complicado.

Tampoco encaja bien con páginas públicas sensibles al SEO. Un rastreador o un visitante normal que solicite una URL pública debería recibir esa página pública directamente, en lugar de verse obligado a pasar primero por un desvío de autenticación.

La regla que utilizo es:

No consultes al emisor al cargar la página. Inicia la autenticación entre dominios solo después de una acción explícita de Iniciar sesión o de una navegación intencional autenticada entre sitios.

Esto mantiene simple el tráfico público y, al mismo tiempo, hace que la autenticación siga siendo rápida para un usuario que ya tiene una sesión válida en el emisor seleccionado.

La validación de la ruta de retorno merece más cuidado que una comprobación de prefijo

La transferencia normalmente necesita recordar dónde debe llegar el navegador después. Ese valor puede convertirse en una redirección abierta si se trata como una URL arbitraria controlada por el cliente.

Siempre que sea posible, el diseño más seguro consiste en no aceptar en absoluto una URL de destino completa. Un identificador corto o una asignación del lado del servidor para destinos conocidos reduce la cantidad de análisis y validación de URL necesarios.

Si es necesario aceptar una ruta relativa, valídala con cuidado y mantenla restringida al sitio de destino.

Un ejemplo deliberadamente reducido —no una rutina completa de validación de redirecciones— es:

function isSafeReturnPath(value) {
  if (!value.startsWith('/')) return false;
  if (value.startsWith('//')) return false;
  if (value.includes('\\\\')) return false;

  const base = 'https://example.invalid';
  const parsed = new URL(value, base);

  return parsed.origin === base;
}

Una implementación real también debe considerar el comportamiento de decodificación, entradas malformadas, caracteres de control, normalización, reglas de enrutamiento de la aplicación y cualquier transformación del proxy o del framework que se produzca antes de generar la redirección.

La invariante de seguridad es más sencilla que los detalles del analizador: la transferencia puede seleccionar una ubicación permitida dentro de la aplicación de destino, nunca un destino externo arbitrario.

Los casos de fallo importan más que el camino ideal

Un inicio de sesión correcto solo demuestra que la ruta básica funciona. Las pruebas más útiles ejercitan los límites de confianza.

Verifiqué el comportamiento en casos como:

  • un origen no autenticado que intenta emitir una transferencia;
  • ausencia de protección CSRF;
  • un origen ausente, opaco o inesperado;
  • un destino no confiable;
  • un destino de retorno no seguro;
  • un ticket presentado al destino incorrecto;
  • un intento de consumo desde un origen incorrecto;
  • repetición del ticket;
  • expiración del ticket;
  • un usuario que ya no existe;
  • un ticket proporcionado mediante la URL en lugar del cuerpo POST esperado.

Dos invariantes son especialmente útiles para probar directamente: una solicitud rechazada no debe destruir por accidente un ticket que todavía sea válido para el flujo legítimo, y un consumo correcto debe hacer que ese ticket sea inutilizable de inmediato.

El protocolo escala sin integraciones por pares

El diseño de transferencia no cambia de manera fundamental cuando se añaden más sitios.

Cada sitio participante necesita el mismo pequeño contrato:

  • un identificador lógico estable;
  • un origen público de confianza;
  • acceso a la identidad de cuenta compartida;
  • una operación de emisión de transferencias para orígenes autenticados;
  • una operación de consumo de transferencias;
  • un registro de sitios de confianza;
  • una sesión local vinculada al host;
  • una política segura para destinos de retorno;
  • y un emisor conocido para flujos anónimos de Iniciar sesión.

La decisión importante para escalar es evitar lógica de autenticación personalizada por pares. Un sitio ya autenticado puede emitir el mismo tipo de transferencia a otro sitio de confianza. Un sitio anónimo puede enviar el navegador al emisor conocido.

Así, el sistema se basa en un único protocolo en lugar de una matriz creciente de casos especiales.

La portabilidad del inicio de sesión es independiente de la propagación del cierre de sesión

Las sesiones independientes vinculadas al host crean otra distinción útil: hacer portátil el inicio de sesión no define automáticamente un cierre de sesión global.

Si el usuario cierra sesión en el Sitio A, la sesión local de otro sitio puede seguir siendo válida salvo que el backend la revoque intencionalmente. Eso es una política de gestión de sesiones, no un fallo de la transferencia SSO.

Un sistema puede elegir entre cierre de sesión local, cierre de sesión global o gestión explícita de sesiones. Mantener esa decisión separada de la transferencia hace que ambos mecanismos sean más fáciles de razonar.

El modelo que utilizo

La arquitectura se volvió más sencilla cuando dejé de describir el problema como “compartir cookies de inicio de sesión entre dominios”.

Existe una identidad global, cada dominio posee su propia sesión del navegador y una transferencia de un solo uso y corta duración permite que un origen autenticado de confianza autorice la creación de otra sesión local.

Para entrar directamente en un sitio anónimo, importa una regla adicional: el navegador debe enviarse a un emisor que realmente pueda observar la sesión existente del usuario. Si el usuario inició sesión anteriormente en ese emisor, el viaje de ida y vuelta puede ser casi invisible. Si el emisor no tiene una sesión válida, debe recurrir a la autenticación normal en lugar de fingir que puede ver la sesión de otro dominio no relacionado.

Este modelo conserva los límites de dominio del navegador, evita redirecciones de autenticación innecesarias para los visitantes públicos y aun así ofrece la experiencia prevista: iniciar sesión en el emisor, abrir más tarde otro sitio de confianza, hacer clic en Iniciar sesión y volver con una nueva sesión local sin introducir otra vez la contraseña.