JWT (JSON Web Token): qué es, cómo funciona y buenas prácticas modernas

Autor: Jeffry Chaves, Ing. en Sistemas – Diccionario Informático

Qué es un JWT y para qué se usa hoy

Un JSON Web Token (JWT) es un token compacto y auto-contenido que transmite claims (afirmaciones) entre partes de forma segura. En cristiano: es una “tarjeta firmada” que el servidor emite y el cliente presenta para demostrar quién es y qué puede hacer. Es ubicuo en APIs REST, microservicios, SPAs (React, Vue, Angular) y móvil con OAuth 2.0/OpenID Connect.

A mí me gustan por su portabilidad y porque evitan consultas al servidor en cada request para resolver la sesión. Pero hay que usarlos con cabeza: los JWT son credenciales y, si se filtran, un atacante puede usarlos hasta que expiren. En mi caso, empecé usando HS256 “por simplicidad” y aprendí que es mejor dar el salto a RS256/ES256 cuando hay varios consumidores y necesitas rotación de claves sin tocar a todos los clientes.

Beneficios rápidos

  • Stateless en los recursos: menos carga de sesión.

  • Interoperabilidad: JSON sobre HTTP, fácil en cualquier stack.

  • Extensible: puedes modelar claims según tu negocio.

Riesgos si se implementa mal

  • Guardar tokens en lugares inseguros (me pasó con localStorage en un staging con XSS).

  • Expiraciones largas que abren ventanas de abuso.

  • Firmas débiles o claves sin rotación.

Estructura de un JWT: header, payload y signature

Un JWT son tres partes en Base64URL separadas por puntos:

  1. Header: tipo y algoritmo. Ejemplo:

    { "alg": "RS256", "typ": "JWT", "kid": "2025-09-key-01" }
    • alg: HS256, RS256, ES256…

    • typ: “JWT”.

    • kid: identificador de clave para key rotation (imprescindible si publicas JWKS).

  2. Payload (claims): datos firmados (no cifrados por defecto).

    {
    "iss": "https://auth.miapp.com",
    "sub": "user_123",
    "aud": "miapp-api",
    "exp": 1767225600,
    "iat": 1767222000,
    "nbf": 1767222060,
    "scp": ["read:orders", "write:orders"]
    }
  3. Signature: asegura integridad y autenticidad. Si alguien altera el payload, la firma no valida.

Nota: si necesitas confidencialidad, usa JWE (JWT cifrado) o no metas información sensible en el payload.

Claims esenciales y recomendados (con ejemplos)

ClaimPara qué sirveRecomendación
issQuién emitió el tokenURL estable del emisor
subIdentidad del sujetoID global e inmutable
audQuién debe aceptar el tokenUn valor por API/cliente
expCuándo expiraTokens cortos (5–15 min)
iatCuándo se emitióÚtil para diagnósticos
nbfNo válido antes de…Margen por clock skew
jtiID único del tokenÚtil para revocación selectiva
scp/scopePermisosManténlo breve; autorización fina fuera del token

En mi caso, cuando el token “engordó” con permisos detallados, moví la autorización granular a un PDP externo (FGA/ABAC) y dejé en el JWT solo sub, iss, aud, exp y un scp breve.

Cómo funciona la autenticación con JWT (paso a paso)

  1. Login (usuario + factor opcional) → el servidor emite access token (corto) y refresh token (más largo).

  2. El cliente llama a la API con Authorization: Bearer <access_jwt>.

  3. La API verifica firma y valida claims (aud, iss, exp, nbf).

  4. Si el access expira, el cliente usa el refresh para obtener uno nuevo.

  5. Rotación: cada uso del refresh invalida el anterior.

Yo probé a almacenar access + refresh en localStorage y me llevé un susto por un XSS en un entorno de pruebas. Desde entonces uso cookies HttpOnly + SameSite=Lax para el refresh, y envío el access token en el header (memoria/estado del cliente, no en storage persistente del navegador).

Validar vs verificar: diferencias que importan en producción

  • Verificar = comprobar firma con la clave adecuada (pública en RS/ES, secreta en HS).

  • Validar = además de la firma, chequear claims: exp, nbf, aud, iss y políticas propias (permisos, tenant, etc.).

En mi experiencia, los bugs más sutiles salieron por clock skew entre servicios: mitigado con NTP y un margen (nbf y tolerancia al validar exp ±30s).

Buenas prácticas: expiración corta, almacenamiento seguro y rotación de claves

Expiración corta
Access tokens entre 5 y 15 minutos; refresh días (según riesgo). Menos tiempo = menor ventana en caso de filtrado.

Almacenamiento

  • Web: refresh en cookie HttpOnly + SameSite=Lax/Strict; access en memoria.

  • Móvil: llévalo al Keychain/Keystore. Yo, en iOS, lo guardo en el Secure Enclave y uso DPoP para atar el token al dispositivo en endpoints críticos.

Rotación de claves (JWKS + kid)
Publica tu JWKS (/.well-known/jwks.json) y firma con claves rotativas identificadas por kid. Así clientes verifican automáticamente sin redeploy.

Revocación/rotación de refresh
Yo mantengo allowlist en Redis con jti y rotación: cada canje de refresh emite uno nuevo e invalida el anterior. Esto corta el replay.

JWT vs sesiones clásicas y vs SAML: cuándo conviene cada uno

  • JWT: APIs modernas, microservicios, multi-cliente. Pros: escalabilidad, desacoplo. Contras: revocación más compleja; cuidado con el tamaño de headers.

  • Sesiones con cookie: apps clásicas servidor-renderizadas. Pros: revocación inmediata; Contras: acoplamiento y sticky sessions.

  • SAML: entornos enterprise/SSO legado. Pros: maduro, XML-centric; Contras: más pesado; integración más compleja en SPAs.

Yo uso JWT para APIs y guardo cookie-sesión solo en paneles administrativos server-renderizados donde la revocación instantánea es crucial.

Errores comunes y cómo evitarlos (con checklist)

Checklist rápido

  • ☐ No metas secretos/datos sensibles en el payload (no está cifrado).

  • ☐ No uses localStorage para refresh; prefiere HttpOnly cookies.

  • ☐ Evita HS256 con secreto débil; considera RS256/ES256 y JWKS.

  • ☐ Define y valida iss, aud, exp, nbf.

  • Access corto; refresh rotado y con jti.

  • ☐ Maneja clock skew.

  • ☐ Limita el tamaño del token (evita permisos kilométricos).

  • ☐ Registra kid y rota claves periódicamente.

En mi equipo, el mayor salto de calidad vino de métricas: loguear fallos de verificación, aud inválidos y expiraciones para detectar clientes desincronizados.

Implementación rápida en Node.js y Spring (snippets)

Node.js (Express + RS256 + JWKS)

npm i express jsonwebtoken jwks-rsa
// auth.js
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const client = jwksClient({
jwksUri: «https://auth.miapp.com/.well-known/jwks.json»,
cache: true, cacheMaxEntries: 5, cacheMaxAge: 10 * 60 * 1000
});function getKey(header, cb) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return cb(err);
const signingKey = key.getPublicKey();
cb(null, signingKey);
});
}

export function requireAuth(audExpected) {
return (req, res, next) => {
const auth = req.headers.authorization || «»;
const token = auth.startsWith(«Bearer «) ? auth.slice(7) : null;
if (!token) return res.status(401).send(«Missing token»);
jwt.verify(token, getKey, {
algorithms: [«RS256»],
audience: audExpected,
issuer: «https://auth.miapp.com»,
clockTolerance: 30
}, (err, payload) => {
if (err) return res.status(401).send(«Invalid token»);
req.user = payload;
next();
});
};
}

// app.js
import express from "express";
import { requireAuth } from "./auth.js";
const app = express();
app.get(«/orders», requireAuth(«miapp-api»), (req, res) => {
res.json({ user: req.user.sub, data: [] });
});
app.listen(3000);

Spring Boot (Spring Security 6 + RS256 + JWKS)

<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
// SecurityConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@org.springframework.context.annotation.Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(«/health»).permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(Customizer.withDefaults()));
return http.build();
}
}
# application.properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.miapp.com
# O usar JWKS directo:
# spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://auth.miapp.com/.well-known/jwks.json

En ambos stacks, yo añado rotación de refresh con Redis y un endpoint /token/refresh que invalida el refresh usado y emite otro. Esto me salvó de un intento de replay en producción.

FAQ sobre JWT (preguntas reales de equipos de desarrollo)

¿JWT es autenticación o autorización?
Es un portador de credenciales. Suele probar identidad (autenticación) y transportar permisos (autorización), pero el control fino puede vivir fuera.

¿Dónde guardo el token en web?
Access en memoria; refresh en cookie HttpOnly con SameSite. Yo migré desde localStorage después de un XSS en staging.

¿Qué duración pongo?
Access 5–15 min; refresh días. Ajusta según riesgo y añade detección de anomalías (IPs/UA inusuales).

¿Cómo revoco tokens ya emitidos?
Incluye jti y usa allowlist/denylist en servidor (Redis). Con access muy cortos, la revocación urgente afecta sobre todo al refresh.

¿Cuándo RS256 vs HS256?
HS256 si controlas emisor y verificador y puedes gestionar secretos robustos; RS256/ES256 si hay múltiples consumidores, necesitas JWKS o rotación fluida.


Conclusión

JWT no es plug-and-play, pero bien implementado te da escalabilidad y seguridad sin fricción. Mi receta: access corto, refresh rotado en cookie HttpOnly, RS256/ES256 con JWKS/kid, validaciones estrictas (iss, aud, exp, nbf) y métricas de uso. Si además extraes la autorización fina fuera del token, ganarás control sin inflar tus headers.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *