Inicio de sesión único entre dos apps sin proveedor de identidad: el ticket de un minuto
Dos herramientas internas en dominios distintos, sin cookie compartida y sin ganas de montar un proveedor de identidad para un puñado de usuarios. Un ticket firmado que vive sesenta segundos y sirve una sola vez reemplazó al segundo login. El flujo, el código y las cuatro respuestas que medimos en producción.
También en: English
Tenemos dos herramientas internas que las mismas personas usan una detrás de la otra. Una es un panel que corre en Vercel. La otra es el editor donde escribimos las propuestas, y corre en nuestro propio servidor. El panel tiene un botón "Abrir en el editor", y hasta el 29 de septiembre de 2026 ese botón te llevaba a un segundo formulario de login: ya habías entrado al panel, y el editor te volvía a pedir email y contraseña.
No pueden compartir una cookie porque viven en dominios distintos. La respuesta habitual es un proveedor de identidad (Auth0, Keycloak, Google Workspace por OIDC) en el que confíen las dos apps. Para dos apps y una lista corta de usuarios, eso es un servicio más que operar, una cuenta más que pagar y un lugar más donde el login se puede romper. Lo resolvimos con un ticket.
El flujo
- La persona, que ya tiene sesión en el panel, aprieta "Abrir en el editor".
- El servidor del panel, después de comprobar que esa persona es dueña,
llama a
POST /api/integracion/sesionen el editor con{ email, a }, dondeaes la ruta del editor que hay que abrir. Se autentica con una API key que el navegador nunca ve. - El editor comprueba que el email esté en su propia lista de usuarios,
firma un ticket y contesta
{ url: "https://<editor>/entrar?t=<ticket>" }. - El panel redirige el navegador a esa URL.
- La página
/entrardel editor canjea el ticket por su sesión de siempre (la misma sesión de next-auth que deja un login con contraseña) y sigue aa.
Las dos apps no comparten ningún secreto. La clave de firma nunca sale del editor: firma el ticket en el paso 3 y lo verifica en el paso 5. El panel sólo tiene una API key que puede pedir tickets.
El ticket
Es un cuerpo JSON en base64url más un HMAC-SHA256 de ese cuerpo:
export const VIDA_MS = 60_000;
const CONTEXTO = 'propuestas:entrada-del-hub:v1';
const firma = (cuerpo, secreto) =>
createHmac('sha256', secreto).update(`${CONTEXTO}.${cuerpo}`).digest('base64url');
export function emitirTicket({ email, a }, { secreto = process.env.NEXTAUTH_SECRET, ahora = Date.now() } = {}) {
if (!secreto) throw new Error('falta NEXTAUTH_SECRET');
const cuerpo = b64(JSON.stringify({
e: email.trim().toLowerCase(),
a: destinoSeguro(a),
x: ahora + VIDA_MS, // vence en 60 s
j: randomBytes(12).toString('base64url'), // id único, para que sirva una vez
}));
return `${cuerpo}.${firma(cuerpo, secreto)}`;
}
e es el email, a el destino, x el vencimiento y j el id del ticket. Hay
dos detalles que es fácil saltarse:
- La cadena de contexto (
propuestas:entrada-del-hub:v1) entra en el HMAC. El editor reutiliza su secreto de sesión, y el contexto evita que esta firma valga en cualquier otro lugar donde ese secreto firme algo. - Sin secreto no hay ticket. Un ticket firmado con
undefinedlo podría falsificar cualquiera, así que la función falla en vez de emitir uno.
El canje revisa, en este orden: la firma (con timingSafeEqual), la forma, el
vencimiento, que el email siga en la lista y que el id no se haya usado. Recién
ahí anota el id como usado y devuelve la sesión. El motivo de un rechazo va al
log del servidor; la pantalla dice lo mismo en todos los casos: el link venció,
vuelve y aprieta de nuevo, o entra con tu contraseña.
Cuatro decisiones que sostienen la seguridad
1. El ticket se pide de servidor a servidor, con una key propia. La key
que usa el panel tiene una sola capacidad, sesion, en una entrada aparte. No
se hereda de las otras keys del panel (la que lee la cartera de propuestas, la
del portal de clientes). Una key que puede sacar tickets para un usuario de la
lista vale exactamente lo mismo que el login de ese usuario, así que no tiene
que venir pegada a nada más.
2. Quién entra lo sigue decidiendo el editor. Que el panel diga "es dueño" no alcanza: el email tiene que estar en la lista de usuarios del propio editor, y se revisa dos veces, al pedir el ticket y al canjearlo, por si la lista cambió en el medio. El ticket no crea usuarios. Cuando lo publicamos, una de las cuentas dueñas del panel no estaba en la lista del editor, y el editor le siguió pidiendo contraseña a esa cuenta. Eso es el diseño funcionando, y el arreglo es sumar la cuenta a la lista, no confiar más en el panel.
3. Sesenta segundos y un solo uso, porque viaja en una URL. Una URL termina en el historial del navegador y en los logs de acceso. Un ticket vencido o ya canjeado ahí no vale nada. El ticket lo usa una redirección que ocurre justo después de emitirlo, así que un minuto deja margen para una red lenta y poco tiempo para cualquier otro.
4. El destino sólo puede ser una ruta del editor. a tiene que empezar
con /, no puede empezar con // ni con /\ y no puede tener saltos de
línea. Cualquier otra cosa se vuelve /. Sin eso, el endpoint del ticket sería
una redirección abierta que además lleva una sesión iniciada.
Cómo lo comprobamos
El 29 de septiembre de 2026 corrimos esto contra producción con curl:
| pedido | respuesta |
|---|---|
| primer canje de un ticket | devuelve la sesión, y /documentos contesta 200 |
| segundo canje del mismo ticket | 401 |
| pedir un ticket sin la key | 401 |
| pedir un ticket para un email que no está en la lista | 403 |
| pedir un ticket con un destino externo | el destino se reescribe a una ruta del editor |
La segunda fila es la que más importa: muestra que el ticket de verdad se quema. Un login que funciona prueba muy poco sobre las partes que tienen que fallar.
Antes de copiarlo
- El registro de usados vive en memoria. El editor corre en un solo
contenedor, así que alcanza con un
Map. Si se reinicia, lo peor que pasa es que un ticket de hace menos de un minuto se pueda canjear una vez más. Con más de una réplica, ese registro tiene que pasar a un almacenamiento compartido (una tabla, Redis), o cada réplica va a aceptar el mismo ticket una vez. - Rotar el secreto de sesión también invalida los tickets en vuelo. Con sesenta segundos de vida, no hace daño.
- La página
/entrarlee el destino del ticket sin verificarlo, sólo para saber a dónde ir después del canje. La verificación ocurre en el servidor, y si falla no hay sesión, así que el destino no abre. Si copias el patrón, respeta ese orden. - Cuándo no usarlo. Esto funciona porque son pocos usuarios internos, dos apps, y una de ellas puede ser la que firma. Con usuarios externos, más apps o la necesidad de cerrar sesión en todas a la vez, usa un proveedor de identidad de verdad.
Reemplazó un segundo login con un módulo chico, una ruta, una página y una API key más. Si tienes dos herramientas que le piden la misma contraseña dos veces a tu equipo, escríbenos y cuéntanos cómo lo tienes armado.