wandres.dev
ARQUITECTURA FULL-STACK · una app real en el edge

Autenticación y sesiones: dónde vive la identidad en el edge

En un servidor regional la sesión vivía en la memoria del proceso o en un Redis a un milisegundo de distancia, y nadie discutía la decisión porque no había decisión que tomar. En el edge no hay memoria compartida entre isolates ni un Redis cercano a todos, así que la pregunta reaparece con toda su fuerza: una sesión puede ser una cookie firmada que no consulta nada, una entrada en KV eventualmente consistente, un Durable Object con estado fuerte o una fila en D1. Recorremos el flujo de login completo, la criptografía real que lo sostiene con WebCrypto, y el criterio que ordena las cuatro opciones: la revocación.

⏱ 24 min

Autenticar es demostrar quién eres una vez; mantener una sesión es decidir durante cuánto tiempo, y con cuánta comprobación, seguimos creyéndote. En una arquitectura regional esa segunda mitad era invisible porque la resolvía la infraestructura: había un almacén rápido cerca de todos los procesos y todo el mundo lo usaba sin pensarlo. En el edge no existe ese lugar privilegiado. Tu código corre en trescientas ciudades simultáneamente, sin memoria compartida y sin un almacén que sea rápido y fuertemente consistente a la vez en todas ellas. Eso obliga a explicitar una decisión que llevaba veinte años tomada por defecto, y la buena noticia es que el criterio para tomarla es sorprendentemente nítido, siempre que aceptes mirar el problema por su lado incómodo: no por cómo se crea la sesión, sino por cómo se destruye.

🎯 Al terminar esta lección sabrás
  • Comparar los cuatro emplazamientos posibles de una sesión en el edge por latencia, coste y capacidad de revocación.
  • Implementar una cookie firmada con HMAC sobre WebCrypto y sus atributos de seguridad correctos.
  • Trazar el flujo completo de login, verificación por petición y cierre de sesión sobre un único Worker.
  • Elegir la estrategia de revocación adecuada al riesgo real de la aplicación y saber qué está aceptando a cambio.

Cuatro sitios donde puede vivir una sesión

Todas las opciones parten del mismo punto: tras un login correcto el servidor entrega al navegador un identificador opaco dentro de una cookie, y en cada petición posterior recupera de ahí la identidad. Lo que cambia radicalmente entre ellas es qué hay detrás de ese identificador y, por tanto, cuánto cuesta comprobarlo y con qué rapidez puedes dejar de honrarlo.

Emplazamiento Coste por petición Revocación Cuándo es la elección correcta
Cookie firmada sin estado Cero subpeticiones, solo CPU de verificación Nula hasta que vence, salvo lista negra aparte Sesiones cortas, contenido de bajo riesgo, tráfico masivo
KV Una lectura, muy barata y rápida en caliente Efectiva, con retardo de propagación de segundos El caso general de casi cualquier producto web
Durable Object Un salto al objeto, con la latencia de su ubicación Inmediata y observable, con expulsión activa Sesiones vivas: presencia, límite de dispositivos, expulsión
D1 Una consulta, contra la primaria si exige frescura Inmediata y transaccional junto al resto del dato Auditoría estricta, sesión ligada a permisos cambiantes

Conviene desconfiar del atractivo de la primera fila. La cookie firmada sin estado es la que mejor rendimiento tiene sobre el papel y la que más problemas produce en operación, porque convierte cada token emitido en una promesa que no puedes retirar: si un usuario cambia de contraseña, si le revocas un permiso o si alguien roba el token, tu sistema seguirá aceptándolo hasta que expire. Todas las mitigaciones habituales (vencimientos muy cortos, tokens de refresco, listas de revocación) consisten en reintroducir estado por la puerta de atrás, y en ese momento la ventaja de no consultar nada ya se ha evaporado.

⚠️
Dónde no guardar el token

Nunca en localStorage. Un token accesible desde JavaScript es un token exfiltrable con cualquier fallo de inyección en cualquier dependencia del frontend. La cookie con HttpOnly es invisible para el script y viaja sola; el precio es que hay que defenderse de CSRF con SameSite y, en formularios sensibles, con un token de doble envío.

El flujo, de principio a fin

Merece la pena verlo entero antes de escribir una línea, porque el flujo tiene dos caminos con costes muy distintos: el del login, que ocurre una vez cada varios días y puede permitirse ser caro, y el de la verificación, que ocurre en cada petición y tiene que ser casi gratis. Confundirlos es el origen de la mayoría de las arquitecturas de sesión lentas.

sequenceDiagram
participant N as navegador
participant W as worker
participant D as d1
participant K as kv
N->>W: POST /login con credenciales
W->>D: buscar usuario y verificar hash
D-->>W: usuario valido
W->>K: guardar sesion con ttl
W-->>N: 302 con cookie httponly firmada
N->>W: GET /panel con cookie
W->>W: verificar firma hmac sin salir del isolate
W->>K: leer sesion por identificador
K-->>W: datos de sesion
W-->>N: html del panel

Observa el paso de verificación de la firma: ocurre dentro del isolate, sin red, y descarta cualquier cookie manipulada antes de gastar una sola lectura en KV. Es una defensa barata contra el abuso más simple, que consiste en bombardear la API con identificadores inventados para forzarte a pagar una lectura por cada uno. La firma convierte ese ataque en trabajo de CPU tuyo del orden de microsegundos y en cero coste de almacenamiento.

Firmar no es cifrar. El identificador viaja legible; lo que la firma garantiza es que fue tu Worker quien lo emitió y que nadie ha cambiado un carácter por el camino. WebCrypto está disponible en el runtime sin dependencias y ofrece HMAC con SHA-256, que es exactamente lo que hace falta.

const enc = new TextEncoder();

async function clave(secreto: string): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    "raw",
    enc.encode(secreto),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"],
  );
}

export async function firmar(valor: string, secreto: string): Promise<string> {
  const firma = await crypto.subtle.sign("HMAC", await clave(secreto), enc.encode(valor));
  const b64 = btoa(String.fromCharCode(...new Uint8Array(firma)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
  return `${valor}.${b64}`;
}

export async function verificar(cookie: string, secreto: string): Promise<string | null> {
  const corte = cookie.lastIndexOf(".");
  if (corte <= 0) return null;
  const valor = cookie.slice(0, corte);
  const b64 = cookie.slice(corte + 1).replace(/-/g, "+").replace(/_/g, "/");
  const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  const ok = await crypto.subtle.verify("HMAC", await clave(secreto), bytes, enc.encode(valor));
  return ok ? valor : null;
}

La comparación la hace crypto.subtle.verify, y esto no es un detalle estilístico: comparar dos cadenas con el operador de igualdad abre un canal lateral temporal que permite reconstruir una firma byte a byte. Delegar en la primitiva del runtime elimina esa clase entera de fallo. El secreto, por supuesto, no vive en el código sino en Secrets Store o como secreto cifrado del Worker, y rotarlo exige aceptar dos claves válidas durante la ventana de transición.

function cabeceraSesion(token: string): string {
  return [
    `sid=${encodeURIComponent(token)}`,
    "Path=/",
    "HttpOnly",
    "Secure",
    "SameSite=Lax",
    "Max-Age=604800",
  ].join("; ");
}

export async function sesionActual(request: Request, env: Env) {
  const cookie = request.headers.get("Cookie") ?? "";
  const bruto = /(?:^|;\s*)sid=([^;]+)/.exec(cookie)?.[1];
  if (!bruto) return null;
  const id = await verificar(decodeURIComponent(bruto), env.SECRETO_SESION);
  if (!id) return null;
  return env.SESIONES.get<{ usuario: string; rol: string }>(`s:${id}`, { type: "json" });
}

Cada atributo de la cookie hace un trabajo concreto y ninguno es decorativo: HttpOnly la esconde del script, Secure impide que viaje sin TLS, SameSite=Lax corta la mayoría de los envíos entre sitios sin romper la navegación normal, y Max-Age fija el techo absoluto de la ventana de confianza. Si tu aplicación vive en varios subdominios, añadir Domain amplía el alcance y también el radio de un compromiso.

La revocación decide la arquitectura

Aquí es donde las cuatro opciones dejan de parecerse. Cerrar sesión desde el propio navegador es trivial en todas: se borra la cookie. Lo difícil es lo otro, invalidar una sesión que ya está en manos de alguien más, y ahí cada opción responde de una manera distinta.

🎫

Sin estado

No puedes. Solo acortar la vida del token y añadir una lista de revocación, que es estado con otro nombre y otra latencia.

🗝️

KV

Un borrado invalida la sesión en todo el planeta en segundos. Suficiente para casi todo, insuficiente si esos segundos importan.

🏛️

Durable Object

Además de invalidar, puedes cerrar los WebSockets abiertos de ese usuario. La única opción que expulsa de verdad, en el acto.

🗃️

D1

La sesión muere en la misma transacción que cambia el permiso. Coherencia perfecta a cambio de consultar la primaria.

El patrón que resuelve bien el noventa por ciento de los casos es híbrido y no aparece en ninguna de las filas por separado: cookie firmada con vencimiento corto para el camino caliente, registro en KV para poder revocar, y un Durable Object por usuario solo cuando hay conexiones vivas que expulsar. Cada capa paga únicamente por lo que aporta.

Una sesión no es un hecho: es una afirmación fechada, y el diseño consiste en decidir cuánto tiempo aceptas creerte algo que ya podría ser falso

Todo el debate sobre dónde guardar la sesión es, en el fondo, un debate epistemológico disfrazado de ingeniería, y verlo así reorganiza el problema entero. Cuando un usuario se autentica, tu sistema adquiere una creencia: que quien sostiene este token es esa persona. Esa creencia era verdadera en el instante de emitirla y desde entonces no ha vuelto a comprobarse; lo único que hacemos en cada petición es verificar que la creencia está bien registrada, no que siga siendo cierta. Entre el momento de la emisión y el momento del uso puede haber ocurrido cualquier cosa: la contraseña cambió, el empleado fue despedido, el portátil se perdió en un tren, el permiso de administrador se retiró en una reunión. La distancia temporal entre el hecho y su comprobación es la ventana de mentira del sistema, y todo el diseño de sesiones consiste en negociar su anchura contra su precio. La cookie sin estado la fija en el vencimiento completo del token y por eso es rapidísima: nunca vuelve a preguntar. KV la reduce a los segundos de propagación y cobra una lectura. El Durable Object la lleva casi a cero y cobra un salto de red hasta donde viva el objeto. D1 la lleva a cero dentro de una transacción y cobra la latencia de la primaria. No existe la opción correcta en abstracto porque no existe una anchura correcta en abstracto: una ventana de treinta minutos es irrelevante en un lector de artículos y es catastrófica en una consola que mueve dinero. Lo que sí existe, y es la marca del ingeniero maduro, es haberla calculado explícitamente y haberla escrito en algún sitio, en lugar de heredarla del valor por defecto de una librería que alguien copió de un tutorial. Quien no sabe decir en cuántos segundos su sistema deja de creerse una identidad revocada, no tiene una arquitectura de sesiones: tiene una costumbre.

⚔️ Construye y rompe tu propia sesión
  1. Implementa el par firmar y verificar con WebCrypto y demuestra con una prueba que una cookie con un solo byte alterado se rechaza.
  2. Monta el flujo completo de login contra D1 y sesión en KV, con las cabeceras de cookie correctas. Verifica los atributos desde las herramientas del navegador.
  3. Mide la diferencia de CPU y latencia entre verificar solo la firma y verificar además contra KV. Anota el coste real de tu revocación.
  4. Añade cierre de sesión global: borra todas las entradas del usuario y comprueba en cuántos segundos deja de aceptarse en otra región.
  5. Sustituye el registro por un Durable Object por usuario y expulsa una conexión WebSocket viva. Compara la experiencia con la de la opción anterior y decide cuál merece su precio en tu caso.