DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Tokens de autenticación: JWT, Paseto y session tokens — el árbol de decisión que me faltaba

Tokens de autenticación: JWT, Paseto y session tokens — el árbol de decisión que me faltaba

¿Por qué seguimos discutiendo sobre JWT como si el problema fuera el formato y no el modelo de amenaza? Llevamos años con ese debate y cada vez que aparece alguien diciendo "JWT es inseguro" o "Paseto lo reemplaza", me pregunto si estamos hablando del mismo problema. El formato del token no es lo que rompe sistemas — es la falta de criterio sobre cuándo usar cada uno.

Mi tesis es incómoda: no existe el token perfecto. Existe el token correcto para el contexto, el equipo y las amenazas reales de cada sistema. JWT tiene problemas documentados. Paseto mejora varios de ellos pero no es magia. Y los session tokens opacos, que casi nadie menciona en estos debates, siguen siendo la opción más simple y segura para la mayoría de las aplicaciones web de propósito general. Si estás construyendo algo con Next.js y no tenés un caso explícito para stateless tokens, probablemente no los necesitás.


El quilombo de fondo: qué dice RFC 7519 y qué no dice

Empiezo por la fuente. RFC 7519 define JWT como un medio compacto para representar claims transferidos entre dos partes. La estructura es conocida: header + payload + signature en base64url, separados por puntos. Lo que el RFC no dice es que JWT sea seguro por defecto — eso depende del algoritmo elegido, del manejo de la firma y de cómo el servidor valida el token.

El problema histórico más famoso de JWT no es la estructura: es el claim "alg": "none" que algunas bibliotecas aceptaban sin requerir firma, y la confusión entre RS256 y HS256 que permitía ataques de confusión de algoritmo. Ambos son errores de implementación, no del formato. Pero el formato los permitía. Eso es relevante.

Lo que RFC 7519 tampoco resuelve:

  • Revocación: un JWT firmado es válido hasta que expira. Si necesitás invalidarlo antes (logout, cambio de contraseña, compromiso de sesión), necesitás una lista negra o un mecanismo externo. Eso elimina parte del beneficio stateless.
  • Tamaño: un JWT con claims típicos de autenticación pesa entre 300 y 600 bytes. En cada request. En headers HTTP. No es un drama, pero tampoco es gratis.
  • Confidencialidad: el payload de un JWT firmado (JWS) está codificado en base64url, no cifrado. Cualquiera que intercepte el token puede leer los claims. Para datos sensibles necesitás JWE, que agrega complejidad de implementación.

Esto no hace a JWT malo. Lo hace específico. Y esa especificidad es exactamente lo que el árbol de decisión tiene que capturar.


Paseto: qué mejora y dónde el hype se adelanta a la realidad

Paseto nació con una premisa honesta: eliminar las decisiones peligrosas que JWT deja en manos del implementador. En JWT podés elegir alg: none, podés usar HS256 con una clave débil, podés ignorar la validación de exp. Paseto elimina esa superficie de error fijando algoritmos por versión.

En Paseto v4 (la versión actual recomendada):

  • v4.local usa XChaCha20-Poly1305 para cifrado autenticado (cifra y autentica en una sola operación).
  • v4.public usa Ed25519 para firma asimétrica.

No hay alg: none. No hay opciones inseguras. El protocolo no las expone.

Pero — y acá está lo que el hype suele omitir — Paseto no resuelve el problema de revocación. Un token v4.public válido sigue siendo válido hasta que expira, igual que JWT. Si necesitás revocar sesiones en tiempo real, seguís necesitando estado en el servidor. El problema no era el algoritmo de firma: era el modelo stateless en sí.

Además, la adopción de Paseto en el ecosistema TypeScript/Node.js es bastante menor que la de JWT. Hay una biblioteca oficial (paseto) mantenida por Panva (el mismo autor de jose), pero el soporte en frameworks, herramientas de debugging y documentación de terceros está lejos del ecosistema JWT. Eso tiene un costo operativo real para equipos que no son expertos en cripto.

Cuando tiene sentido ir con Paseto v4:

  • Sistemas nuevos donde el equipo puede invertir en la curva de aprendizaje.
  • APIs que manejan datos sensibles y quieren v4.local (cifrado incluido en el token).
  • Equipos que quieren reducir superficie de error en la elección de algoritmo.

Cuando Paseto no agrega valor suficiente para justificar el costo:

  • Sistemas existentes con JWT bien implementado (HMAC con clave fuerte, validación de exp y iss, algoritmo fijado).
  • Equipos pequeños con poco tiempo para invertir en adopción de tooling nuevo.
  • Casos donde la revocación es un requerimiento central — ahí el formato del token es irrelevante.

El árbol de decisión: preguntas en orden

Antes del código, el criterio. Estas preguntas tienen que responderse en orden porque cada una filtra opciones:

¿Necesitás invalidar tokens antes de que expiren
(logout, cambio de contraseña, compromiso de cuenta)?
│
├── SÍ → Session tokens opacos + store en servidor (Redis, DB)
│         JWT o Paseto con blocklist (elimina la ventaja stateless)
│
└── NO → ¿Tenés múltiples servicios que consumen el token
          sin coordinación centralizada?
          │
          ├── SÍ → JWT (RS256/ES256) o Paseto v4.public
          │         (verificación local, sin llamada al servidor de auth)
          │
          └── NO → ¿El payload contiene datos sensibles
                    que no deben ser legibles si el token se intercepta?
                    │
                    ├── SÍ → Paseto v4.local (cifrado + autenticado)
                    │         o JWE si ya tenés infraestructura JWT
                    │
                    └── NO → JWT (HS256 con clave fuerte) o
                              session tokens opacos son ambos válidos.
                              Elegí el más simple para el equipo.
Enter fullscreen mode Exit fullscreen mode

Mi punto en este árbol: la mayoría de las aplicaciones web con un solo backend y sesiones de usuario caen en la rama "NO / NO / NO" — y ahí la respuesta correcta es session tokens opacos. Son un string aleatorio criptográficamente seguro, guardado en una cookie HttpOnly + Secure + SameSite=Strict, con el estado de sesión en el servidor. Nada que revocar a ciegas, nada que implementar de cripto, nada que debuggear con jwt.io.


Implementación mínima reproducible en TypeScript

Session token opaco (el caso más común)

import crypto from "node:crypto";

// Generar un token opaco — 32 bytes = 256 bits de entropía
function generarSessionToken(): string {
  return crypto.randomBytes(32).toString("hex");
}

// En la respuesta al login, seteás la cookie así:
// Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/

// En cada request, buscás el token en tu store (Redis, DB)
async function validarSesion(
  token: string
): Promise<SesionUsuario | null> {
  // El token no tiene estado propio — toda la info está en el servidor
  return await sessionStore.get(token) ?? null;
}
Enter fullscreen mode Exit fullscreen mode

JWT con RS256 (para arquitecturas multi-servicio)

import { SignJWT, jwtVerify, generateKeyPair } from "jose";

// Generá el par de claves una vez y guardalo de forma segura
const { privateKey, publicKey } = await generateKeyPair("RS256");

// Firma del token — el iss y exp son obligatorios para validación correcta
async function firmarToken(userId: string): Promise<string> {
  return new SignJWT({ sub: userId })
    .setProtectedHeader({ alg: "RS256" })
    .setIssuedAt()
    .setIssuer("https://auth.miapp.com")   // iss: quién emitió el token
    .setAudience("https://api.miapp.com")  // aud: para quién es válido
    .setExpirationTime("15m")              // exp corto — sin revocación fácil
    .sign(privateKey);
}

// Verificación — el audience y el issuer tienen que coincidir
async function verificarToken(token: string) {
  const { payload } = await jwtVerify(token, publicKey, {
    issuer: "https://auth.miapp.com",
    audience: "https://api.miapp.com",
  });
  return payload;
}
Enter fullscreen mode Exit fullscreen mode

Paseto v4.public (asimétrico, sin opciones peligrosas)

import { V4 } from "paseto";

// Paseto v4.public usa Ed25519 — el algoritmo está fijado por el protocolo
const secretKey = await V4.generateKey("public");

async function firmarTokenPaseto(userId: string): Promise<string> {
  return V4.sign(
    {
      sub: userId,
      exp: new Date(Date.now() + 15 * 60 * 1000).toISOString(), // 15 minutos
    },
    secretKey,
    { footer: { iss: "https://auth.miapp.com" } }
  );
}

async function verificarTokenPaseto(token: string) {
  // Sin opción de cambiar algoritmo — eso es exactamente el punto
  return V4.verify(token, secretKey.publicKey);
}
Enter fullscreen mode Exit fullscreen mode

Errores comunes que no son obvios

1. JWT con HS256 compartido entre servicios
HMAC con clave secreta compartida significa que cualquier servicio que pueda verificar el token también puede emitirlo. En arquitecturas de microservicios eso es una superficie de ataque real. RS256 o ES256 separan la clave de firma (privada, solo el emisor) de la clave de verificación (pública, cualquier servicio).

2. Asumir que "stateless" elimina el estado
Si implementás revocación con blocklist, ya tenés estado. Si verificás el token contra la DB en cada request para chequear si el usuario sigue activo, ya tenés estado. En ese punto, un session token opaco es más simple porque no añade overhead de verificación de firma además del acceso a la DB.

3. Payload de JWT en el cliente
El payload de un JWT firmado es legible por cualquiera (base64url no es cifrado). Si guardás roles, permisos o cualquier dato que no querés exponer en el cliente, usá JWE o no los metas en el token. Esto no es un bug de JWT — está en la spec — pero en la práctica muchos equipos lo descubren tarde.

4. Expiración larga como solución a la UX
A veces el equipo sube el exp a 30 días para no molestar al usuario con re-logins. Eso convierte un token sin revocación en un problema de seguridad real. La solución correcta es un access token corto (15-60 minutos) más un refresh token con rotación, no alargar el exp del access token.

Si estás usando Next.js Middleware para proteger rutas con JWT, el modelo de access + refresh token es especialmente relevante — lo desarrollé en el post sobre patrones de autorización en Next.js 16 Middleware.


Lo que no podés concluir sin datos propios

Esto importa: todo lo de arriba es análisis basado en la spec y en principios de diseño. Hay cosas que este post no puede resolver porque dependen de variables de cada sistema:

  • Latencia real de revocación: cuánto impacta una blocklist en Redis en el throughput de una aplicación depende de la arquitectura, el tamaño del store y los patrones de acceso. No tengo esos números para el sistema tuyo.
  • Overhead de verificación de firma: la diferencia entre HS256, RS256 y Ed25519 en throughput real es medible pero varía según hardware, biblioteca y volumen de requests. Si eso es crítico para el sistema propio, medilo con una prueba reproducible en el entorno correspondiente.
  • Compatibilidad de Paseto con tu stack: no todos los frameworks y proxies conocen Paseto. Antes de adoptarlo, verificá el soporte en cada capa del stack.

La tesis de este post no depende de esos números. Pero las decisiones de implementación sí.


FAQ: preguntas que recibo seguido sobre este tema

¿JWT es inseguro?
No intrínsecamente. El RFC 7519 define una estructura válida. Los problemas históricos (como alg: none) eran bugs de implementación en bibliotecas específicas que aceptaban algoritmos nulos. JWT bien implementado — con algoritmo fijado, validación de exp, iss y aud, y clave fuerte — es seguro para la mayoría de los casos de uso. El problema no era el formato: era el exceso de flexibilidad que dejaba demasiadas decisiones peligrosas en manos del desarrollador.

¿Paseto reemplaza a JWT?
Técnicamente puede cumplir los mismos casos de uso que JWT firmado. Pero "reemplazar" implica una migración de ecosistema, tooling y conocimiento del equipo. Paseto mejora la ergonomía de seguridad (sin opciones peligrosas, algoritmos fijados) pero no resuelve revocación ni cambia el modelo stateless. Para sistemas nuevos con un equipo dispuesto a invertir en la curva, es una buena opción. Para sistemas JWT existentes bien implementados, el costo de migración rara vez se justifica solo por el cambio de formato.

¿Cuándo usar session tokens opacos en vez de JWT?
Cuando la aplicación es un monolito o tiene un único backend, cuando necesitás revocación inmediata, cuando el equipo es pequeño y querés reducir superficie de implementación, o cuando no tenés un caso claro para tokens stateless. Los session tokens opacos con cookie HttpOnly son el patrón más simple y tienen décadas de práctica operativa detrás.

¿Puedo guardar el JWT en localStorage?
Podés, pero no es recomendable para tokens de autenticación. localStorage es accesible desde JavaScript, lo que lo expone a XSS. Una cookie HttpOnly con el token — sea opaco o JWT — no es accesible desde JavaScript del cliente. Si la aplicación tiene cualquier vector de XSS (incluyendo dependencias de terceros), localStorage amplifica el daño.

¿Cómo manejo el refresh de JWT en Next.js?
El patrón típico es un access token de vida corta (15 minutos) en cookie o memoria del cliente, y un refresh token de vida larga en cookie HttpOnly. El Middleware de Next.js puede interceptar requests con access token expirado, hacer un refresh transparente y continuar. La complejidad está en la rotación del refresh token y en evitar race conditions cuando múltiples tabs disparan el refresh simultáneamente.

¿Qué biblioteca de JWT uso en TypeScript?
jose de Panva es la recomendación más sólida hoy — es la que usa Next.js internamente, cumple con los RFCs, tiene soporte activo y funciona en Edge Runtime. jsonwebtoken sigue siendo popular pero no tiene soporte nativo para Edge y tiene limitaciones en algoritmos modernos. Para Paseto, paseto del mismo autor.


Mi postura, sin ambigüedad

Después de haber visto sistemas que migraron a JWT por moda y terminaron construyendo una blocklist completa (con lo que perdieron el único beneficio del modelo), y sistemas que se quedaron con session cookies simples y funcionan perfectamente a escala, mi posición es esta:

Empezá con session tokens opacos. Si en algún momento el sistema crece hacia una arquitectura donde múltiples servicios independientes necesitan verificar identidad sin coordinación centralizada, ahí JWT o Paseto tienen sentido real. No antes.

Lo incómodo: el ecosistema JavaScript tiende a sobrecomplicar autenticación. Hay librerías de autenticación "llave en mano" que usan JWT internamente para todo, incluso para aplicaciones monolíticas donde no agrega valor. El resultado es complejidad operativa extra (manejo de claves, rotación, refresh) sin un beneficio técnico claro.

Si querés ir más profundo en la capa de validación que debería rodear cualquiera de estas decisiones, el post sobre Zod para validación en runtime conecta bien con esto — validar el payload del token antes de usarlo es un paso que se omite más seguido de lo que debería.


Fuentes originales:


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)