DEV Community

Cover image for JWT: el token firmado que reemplaza la sesión guardada en el servidor
lu1tr0n
lu1tr0n

Posted on • Originally published at elsolitario.org

JWT: el token firmado que reemplaza la sesión guardada en el servidor

Cada vez que un navegador manda una petición a una API y el servidor responde sin haber tocado la base de datos para saber quién sos, hay un token JWT haciendo ese trabajo. La sigla significa JSON Web Token y es, hoy, el mecanismo más usado para autenticar usuarios en APIs REST, apps móviles y arquitecturas de microservicios.

Este artículo explica la estructura exacta de un token JWT, cómo firmarlo y verificarlo con código real en Node.js, cuándo conviene usarlo y cuándo una sesión tradicional con cookie sigue siendo la mejor opción.

TL;DR

  • Vas a entender la estructura exacta de un JWT: header, payload y firma, y por qué cualquiera puede leerlo sin poder falsificarlo.- Vas a implementar login con JWT en Express usando jsonwebtoken, desde firmar el token hasta protegerlo con middleware.- Vas a saber por qué guardar un JWT en localStorage es un riesgo de XSS y qué alternativa usar en su lugar.- Vas a diferenciar JWT de sesiones con cookies y de tokens opacos, y cuándo conviene cada enfoque.- Vas a aprender a verificar la validez de un token con el debugger de jwt.io y con un script propio en Node.- Vas a diseñar un esquema de refresh tokens para revocar acceso sin esperar a que expire el JWT.- Vas a identificar el error más común: no validar el algoritmo de firma y quedar expuesto a un bypass.

Qué es un token JWT y por qué reemplazó a la sesión en servidor

Antes de JWT, la forma estándar de mantener a un usuario autenticado era la sesión en servidor: el usuario hacía login, el servidor guardaba un identificador de sesión en memoria o en Redis, y mandaba ese identificador al navegador dentro de una cookie. En cada petición siguiente, el servidor tenía que buscar esa sesión en su almacenamiento para saber quién era el usuario.

Un token JWT invierte el modelo: en lugar de guardar el estado en el servidor, lo guarda dentro del propio token, firmado. El servidor emite el token una sola vez, y en cada petición futura solo necesita verificar la firma matemáticamente, sin consultar ninguna base de datos ni caché. Esto es lo que se conoce como autenticación stateless (sin estado).

El formato quedó estandarizado en el RFC 7519 de la IETF, y desde entonces es la base de OAuth 2.0, OpenID Connect y prácticamente todos los SDKs de autenticación modernos (Auth0, Firebase Auth, AWS Cognito). Un JWT no reemplaza a OAuth: OAuth define cómo se autoriza el acceso entre aplicaciones, y JWT es frecuentemente el formato que usa OAuth para representar ese acceso una vez otorgado.

Cómo funciona un token JWT por dentro

Un JWT es una cadena de texto con tres partes separadas por puntos: header.payload.signature. Cada parte está codificada en base64url, no cifrada: cualquiera con el token puede decodificar y leer el contenido sin necesitar la clave secreta.

El header declara el algoritmo de firma y el tipo de token, por ejemplo {"alg":"HS256","typ":"JWT"}. El payload contiene los claims: datos como el id del usuario, su rol, y cuándo expira el token (el claim exp). La firma se calcula aplicando el algoritmo declarado en el header sobre el header y el payload concatenados, usando una clave secreta (o privada) que solo el servidor conoce.
Header, payload y firma viajan codificados, no cifrados.

flowchart LR
    A["Header: alg y typ"] --> D["Token completo firmado"]
    B["Payload: claims como sub y exp"] --> D
    C["Firma: HMAC o RSA sobre header y payload"] --> D
Enter fullscreen mode Exit fullscreen mode

Que el payload no esté cifrado es una decisión de diseño, no un descuido: permite que cualquier servicio intermedio (un API Gateway, por ejemplo) lea el rol del usuario sin necesitar la clave de firma, algo útil en arquitecturas de microservicios. Si necesitás ocultar el contenido, el estándar define JWE (JSON Web Encryption) como una capa adicional, aunque en la práctica casi nadie la usa porque los datos sensibles simplemente no deberían viajar dentro de un JWT.

⚠️ Ojo: nunca pongas contraseñas, números de tarjeta ni datos sensibles dentro del payload de un JWT. Cualquiera que intercepte el token (o lo pegue en jwt.io) puede leerlo sin decodificar nada especial.

El ciclo de vida completo: login, verificación y expiración

sequenceDiagram
    participant U as Usuario
    participant S as Servidor de autenticacion
    participant R as API protegida
    U->>S: envia usuario y contrasena
    S-->>U: responde con JWT firmado y exp en 15 minutos
    U->>R: solicita recurso con el JWT en el header Authorization
    R-->>U: verifica la firma y devuelve los datos
    Note over U,R: el servidor no consulta la base de datos para validar el token
Enter fullscreen mode Exit fullscreen mode

El claim exp define un timestamp Unix a partir del cual el token deja de ser válido. Cuando expira, el servidor responde con un 401 y el cliente necesita pedir uno nuevo, típicamente usando un refresh token de vida más larga que se intercambia por un JWT fresco sin pedirle la contraseña de nuevo al usuario.

La ventana de expiración es la principal palanca de seguridad de un sistema basado en JWT: cuanto más corta, menos tiempo queda expuesto un token robado, pero más peticiones de refresh tiene que manejar el servidor de autenticación.

Ejemplos prácticos con código

El primer ejemplo muestra algo importante: decodificar un JWT no requiere ninguna librería ni la clave secreta, porque el payload no está cifrado.

const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6IkFuYSJ9.dGVzdC1zaWduYXR1cmU";
const [header, payload] = token.split(".");
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
console.log(decodedPayload);
// { sub: '1234', name: 'Ana' }
Enter fullscreen mode Exit fullscreen mode

Este código separa el token por los puntos y decodifica la segunda parte de base64url a JSON. El resultado muestra los claims tal cual, sin haber verificado si la firma es válida: por eso decodificar nunca reemplaza a verificar.

El segundo ejemplo sí verifica la firma, usando la librería jsonwebtoken de Auth0 en un servidor Express real:

const jwt = require("jsonwebtoken");
const SECRET = process.env.JWT_SECRET;

app.post("/login", (req, res) => {
  const usuario = autenticarUsuario(req.body);
  const token = jwt.sign(
    { sub: usuario.id, rol: usuario.rol },
    SECRET,
    { algorithm: "HS256", expiresIn: "15m" }
  );
  res.json({ token });
});

function requiereAuth(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.replace("Bearer ", "");
  try {
    req.usuario = jwt.verify(token, SECRET, { algorithms: ["HS256"] });
    next();
  } catch (err) {
    res.status(401).json({ error: "token invalido o expirado" });
  }
}

app.get("/perfil", requiereAuth, (req, res) => {
  res.json({ id: req.usuario.sub, rol: req.usuario.rol });
});
Enter fullscreen mode Exit fullscreen mode

La ruta /login firma un token de 15 minutos con el id y el rol del usuario. El middleware requiereAuth lee el header Authorization: Bearer <token>, verifica la firma con jwt.verify y, si es válida, deja pasar la petición con req.usuario ya disponible en /perfil. Nótese que algorithms: ["HS256"] se pasa explícito: es la línea que evita el bypass que se explica más abajo.

Cómo empezar: implementar JWT paso a paso

Para reproducir el ejemplo anterior en un proyecto Node.js nuevo, los pasos son:

npm install express jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

Después hay que definir la variable de entorno con el secreto de firma, nunca hardcodeado en el código:

JWT_SECRET=un-secreto-largo-y-aleatorio-de-al-menos-32-bytes
Enter fullscreen mode Exit fullscreen mode

Con eso ya alcanza para levantar las dos rutas del ejemplo (/login y /perfil) y probarlas con curl:

curl -X POST http://localhost:3000/login -d '{"user":"ana"}' -H "Content-Type: application/json"
curl http://localhost:3000/perfil -H "Authorization: Bearer TOKEN_RECIBIDO"
Enter fullscreen mode Exit fullscreen mode

Verificar un JWT es una operación criptográfica local, sin red.
Para confirmar qué contiene un token sin escribir código, se puede pegar en el debugger de jwt.io, que muestra header y payload decodificados y valida la firma si le das el secreto. Para confirmarlo desde la terminal, sin depender de un sitio externo:

node -e "console.log(require('jsonwebtoken').decode(process.argv[1]))" $TOKEN
Enter fullscreen mode Exit fullscreen mode

Ese comando decodifica el token pasado como argumento y muestra sus claims por consola, útil para depurar en scripts sin abrir un navegador.

Casos de uso reales

Los JWT se usan sobre todo en tres escenarios: APIs consumidas por apps móviles (donde no hay cookies del navegador disponibles de forma nativa), arquitecturas de microservicios (donde cada servicio verifica el token de forma independiente sin compartir una base de sesiones central), y esquemas de Single Sign-On tipo OpenID Connect, donde un proveedor de identidad emite el token una vez y varias aplicaciones distintas lo aceptan.

En los tres casos el patrón es el mismo: evitar que cada servicio tenga que hacer una llamada de red a un almacén de sesiones compartido solo para saber quién hizo la petición.

Errores comunes y buenas prácticas

El error más citado en la documentación de seguridad de JWT es aceptar el algoritmo none: algunas librerías viejas permitían un token sin firma si el header declaraba "alg":"none". Un atacante podía editar el payload, quitar la firma, y el servidor lo aceptaba igual como válido si no restringía explícitamente qué algoritmos esperaba. Por eso el ejemplo de código de este artículo pasa algorithms: ["HS256"] al verificar, en vez de confiar en lo que el propio token dice en su header.

El segundo error común es dónde se guarda el token en el navegador. Guardarlo en localStorage es cómodo, pero cualquier script que logre inyectarse vía XSS puede leerlo directamente con JavaScript. La guía de OWASP sobre JWT recomienda una cookie httpOnly y secure, que JavaScript no puede leer ni siquiera si hay una inyección activa.

💡 Tip: combiná JWT de vida corta (5 a 15 minutos) con un refresh token de vida larga guardado en una cookie httpOnly. Así limitás el daño de un token robado sin obligar al usuario a reloguearse todo el tiempo.

Un tercer error es no fijar expiración: un JWT sin exp es válido para siempre, lo que anula cualquier estrategia de revocación posterior.

JWT vs sesiones vs tokens opacos

OpciónCuándo usarlaVentajaLimitaciónJWT (stateless)APIs distribuidas, apps móviles, microserviciosNo necesita consultar base de datos en cada requestNo se puede revocar antes de que expire sin una lista negraSesión con cookieApps monolíticas renderizadas en el servidorRevocación inmediata: basta con borrar la sesión del storeRequiere consultar Redis o la base de datos en cada requestToken opaco (referencia)Cuando la revocación instantánea importa más que evitar el lookupSe invalida al instante, no expone ningún claim si se filtraNecesita un lookup en cada verificación, igual que una sesión

Profundizando: revocación, refresh tokens y algoritmos de firma

stateDiagram-v2
    [*] --> Emitido
    Emitido --> Valido
    Valido --> Expirado: se cumple el claim exp
    Valido --> Revocado: se agrega a una lista negra en redis
    Expirado --> [*]
    Revocado --> [*]
Enter fullscreen mode Exit fullscreen mode

La limitación más citada de JWT es justamente esa: una vez firmado, es válido hasta que expira, y el servidor no tiene forma nativa de invalidarlo antes. Las tres estrategias reales para mitigar esto son mantener una lista negra de tokens revocados en Redis (consultada solo quien necesite chequear revocación explícita, no cada request), usar expiraciones cortas combinadas con refresh tokens revocables, o rotar el secreto de firma completo, lo que invalida absolutamente todos los tokens emitidos de una sola vez.

Sobre el algoritmo de firma: HS256 usa un único secreto compartido entre quien firma y quien verifica, simple pero exige que todos los servicios que verifiquen el token conozcan ese secreto. RS256 usa un par de claves pública y privada: solo el servidor de autenticación tiene la clave privada para firmar, y cualquier otro servicio puede verificar con la clave pública sin poder falsificar un token nuevo. En sistemas con múltiples microservicios verificando tokens, RS256 evita distribuir un secreto compartido a todos ellos.

📖 Resumen en Telegram: Ver resumen

Tu próximo paso: cloná el ejemplo de Express de este artículo, agregale un endpoint de refresh token con una cookie httpOnly, y probá qué pasa cuando dejás expirar el JWT a propósito.

Preguntas frecuentes

¿Un JWT está cifrado?

No. Por defecto un JWT firmado (JWS) solo está codificado en base64url y firmado, no cifrado: cualquiera puede leer su contenido decodificándolo. Si necesitás ocultar los datos, el estándar define JWE (JSON Web Encryption) como capa adicional.

¿Dónde debería guardar el JWT en el navegador?

En una cookie httpOnly y secure, no en localStorage. localStorage es accesible desde JavaScript, así que un ataque XSS puede robar el token directamente.

¿Cómo revoco un JWT antes de que expire?

JWT no soporta revocación nativa. Las opciones son mantener una lista negra de tokens invalidados en Redis, usar expiraciones cortas con refresh tokens, o rotar el secreto de firma, lo que invalida todos los tokens a la vez.

¿Qué algoritmo de firma debería usar, HS256 o RS256?

HS256 usa un secreto compartido, útil cuando el mismo servicio firma y verifica. RS256 usa un par de claves pública y privada, ideal cuando varios servicios necesitan verificar el token sin poder firmar uno nuevo.

¿Por qué es peligroso aceptar el algoritmo none?

Algunas librerías viejas permiten un JWT sin firma si el header declara alg: none. Un atacante puede editar el payload, quitar la firma, y el servidor lo acepta como válido si no restringe explícitamente qué algoritmos espera al verificar.

Referencias

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Top comments (0)