Tabla de Contenidos

06 - Login con JWT

El login con JWT mantiene la idea del par de tokens: un token de acceso de vida corta y un refresh token de vida larga. La diferencia está en el token de acceso: es autocontenido y verificable sin conservar estado en el servidor.

Se asume conocido el flujo con tokens opacos: par acceso/refresh, rotación, Principal y filtro Servlet que deja la identidad en el contexto de la petición.

1. Token opaco frente a JWT

Un token opaco es una cadena aleatoria sin significado para el cliente; el servidor lo valida consultando su estado. Un JWT es un token con contenido firmado: el servidor lo valida comprobando la firma y la caducidad, sin consultar la base de datos.

Aspecto Token opaco JWT de acceso
Contenido Aleatorio, sin datos legibles Identidad y claims firmados
Validación Búsqueda en el almacén de tokens Verificación de firma, emisor y caducidad
Estado en servidor Necesario para cada petición No necesario para autenticar
Revocación Revocable marcando su estado No revocable sin estado adicional
Tamaño Corto Más largo, viaja en cada petición
El carácter stateless se refiere solo a la autenticación con el JWT de acceso. El refresh token sigue siendo opaco y stateful: se almacena su hash, su caducidad y su estado de revocación o rotación.

2. Estructura y claims

Un JWT tiene tres partes unidas por puntos: cabecera, carga útil y firma. Cada parte se codifica en Base64 apto para URL:

--> payload : "."Cabeceratipo + algoritmoCarga útilclaimsFirmaverificaciónEl token viaja como una sola línea: cabecera.carga.firma.

<cabecera-en-base64>.  // cabecera
<carga-en-base64>.  // carga útil (claims)
<firma-en-base64>  // firma

Los claims son las afirmaciones contenidas en la carga útil:

Claim Significado
iss Emisor esperado del token
sub Sujeto, normalmente el id del usuario
roles Roles necesarios para autorizar, por ejemplo ROLE_USER
iat Momento de emisión
exp Momento de caducidad

Ejemplo de carga útil decodificada:

{
  "iss": "api-ejemplo",
  "sub": "42",
  "roles": ["ROLE_USER"],
  "iat": 1760515200,
  "exp": 1760516100
}

La firma se calcula sobre cabecera y carga con una clave conocida solo por el servidor. En los ejemplos se usa JJWT con HS256, firma simétrica: el mismo secreto firma y verifica.

La carga útil va solo firmada, no cifrada. Cualquiera puede decodificar el Base64 y leerla. No incluir contraseñas, datos personales sensibles ni información interna en los claims.

3. Emisión: JWT corto y refresh opaco

Al iniciar sesión, el servidor valida las credenciales y emite un par con naturalezas distintas:

Token Naturaleza Vida típica
Acceso JWT Autocontenido y firmado Corta, de 5 a 15 minutos
Refresh token Opaco, aleatorio, almacenado como hash Larga, días o semanas

ClienteServidorAlmac.n de refreshClienteServidorAlmacén de refreshClienteServidorAlmacén de refreshcredencialesautentica y firma JWTregistra hash del refreshJWT de acceso + refresh opaco

La generación firma los claims con el emisor, el sujeto, los roles y las fechas de emisión y caducidad:

String generateAccessToken(User user, Instant issuedAt, Instant expiresAt) {
    return Jwts.builder()
            .issuer(issuer)
            .subject(user.id().toString())
            .claim("roles", user.roles().stream().sorted().toList())
            .issuedAt(Date.from(issuedAt))
            .expiration(Date.from(expiresAt))
            .signWith(secretKey)
            .compact();
}

El refresh token se genera con un generador criptográficamente seguro y se almacena su hash, nunca el valor reutilizable ni en logs. Su rotación y la detección de reuso siguen las mismas reglas que con tokens opacos.

4. Validación stateless en el filtro

En cada petición protegida, el filtro extrae el JWT de la cabecera Authorization con el esquema Bearer, verifica firma, emisor y caducidad, y construye el Principal solo con los claims. No consulta la base de datos:

ClienteFiltroServidorClienteFiltroServidorClienteFiltroServidorpetición + Bearer JWTverifica firma, emisor y caducidadcontinúa con Principal de los claimsautoriza la operaciónresultado o acceso denegado

Optional<AuthenticatedPrincipal> parseAccessToken(String token) {
    try {
        Claims claims = Jwts.parser()
                .verifyWith(secretKey)
                .requireIssuer(issuer)
                .build()
                .parseSignedClaims(token)
                .getPayload();
        return Optional.of(new AuthenticatedPrincipal(
                Long.valueOf(claims.getSubject()), extractRoles(claims)));
    } catch (JwtException | IllegalArgumentException exception) {
        return Optional.empty();
    }
}

La autenticación delega en ese parseado. Si el token falta, está mal formado, tiene otra firma, otro emisor o ha caducado, no hay identidad autenticada:

Optional<AuthenticatedPrincipal> authenticate(String accessToken) {
    if (accessToken == null || accessToken.isBlank()) {
        return Optional.empty();
    }
    return parseAccessToken(accessToken);
}

El filtro deja el resultado en el contexto de la petición y lo limpia al finalizar, como en el filtro de tokens opacos. Las rutas públicas continúan sin Principal. Los roles usados para autorizar son los congelados en el claim roles. Si una operación necesita datos actuales —perfil completo, habilitación o permisos—, esa lectura es una consulta aparte a la BBDD.

Validar siempre firma, iss y exp. La firma demuestra que el contenido no se manipuló y que lo generó quien conoce el secreto, usando la clave y el algoritmo esperados. Un parser que acepte alg none admite cargas forjadas sin firma: el atacante escribe el sub y los roles que quiera y el servidor los creería. Comprobar iss delimita el dominio de confianza: sin esa comprobación, el servidor acepta tokens emitidos por otro entorno o servicio. Exigir exp limita la ventana de exposición: sin caducidad, un token robado vale indefinidamente.

5. Límites del JWT puro

Como el filtro no consulta la base de datos, todo cambio posterior a la emisión queda invisible hasta que el token caduca:

Problema Por qué ocurre Mitigación
Roles modificados Los roles quedan congelados en el token - TTL corto (5-15 min).
- Consultar la BBDD en operaciones críticas
Usuario deshabilitado o eliminado El JWT anterior sigue aceptado hasta exp - TTL corto.
- El refresh sí es revocable y cierra la sesión futura
Token robado No hay revocación sin guardar estado - TTL corto.
- Rotar el refresh ante sospecha.
- Denylist solo si se acepta perder stateless
Datos sensibles en claims La carga es legible por cualquiera - Incluir solo id y roles.
- Nunca secretos ni datos personales
Secreto comprometido o rotado La misma clave verifica todos los tokens - Clave de al menos 256 bits externalizada.
- Rotación planificada
Relojes desincronizados iat y exp dependen de la hora - Margen de tolerancia pequeño.
- Sincronización entre servidores
Un JWT puro no permite expulsar de inmediato ni degradar permisos: solo cabe esperar a que caduque. Por eso el acceso debe durar minutos y la revocación real vive en el refresh token opaco.
Una denylist es un registro de tokens revocados —normalmente sus identificadores— que el servidor consulta en cada petición hasta que caducan (exp) y después puede purgar. Restaura la revocación inmediata a cambio de volver a guardar y consultar estado en la ruta crítica. Solo compensa en incidentes puntuales, no como diseño permanente de un flujo stateless.

6. Pruebas

Las pruebas del JWT aíslan el parseado y el filtro; las del refresh integran el almacén. No probar la persistencia a través del filtro stateless ni la firma a través de la base de datos.