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.localusa XChaCha20-Poly1305 para cifrado autenticado (cifra y autentica en una sola operación).v4.publicusa 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
expyiss, 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.
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;
}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;
}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);
}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:
- RFC 7519 — JSON Web Token (JWT): https://datatracker.ietf.org/doc/html/rfc7519
- Paseto — Platform-Agnostic Security Tokens: https://paseto.io/
Artículos Relacionados
Zod en el servidor y en el cliente: el schema que creés una vez y las tres formas en que se rompe en runtime
Zod se vende como 'definí una vez, validá en todos lados'. En Next.js 16 con Server Actions, edge middleware y API routes, eso es solo parcialmente cierto. Tres modos de falla concretos y el patrón que los evita.
Prisma query logging y PostgreSQL: cuándo alcanza el ORM y cuándo tenés que ir más abajo
Los logs de Prisma Client muestran qué queries generó el ORM. PostgreSQL tiene su propia capa de observabilidad. Confundir una con la otra es la fuente de diagnósticos incompletos. Acá separo los dos planos y te doy un criterio concreto para elegir cuál mirar primero.
Next.js App Router caching: revalidate, dynamic y no-store sin folklore
El problema con el caching en Next.js App Router no es memorizar flags. Es entender qué frescura necesita cada dato y tratarlo como un contrato explícito, no como un truco aislado que se aplica cuando algo falla.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.