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.
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 |
Un JWT tiene tres partes unidas por puntos: cabecera, carga útil y firma. Cada parte se codifica en Base64 apto para URL:
<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.
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 |
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.
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:
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.
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.
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 |
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.
none rechazados.Principal son los del claim, sin consultar la base de datos.Bearer válida y ausente o mal formada; rutas públicas sin identidad; limpieza del contexto tras la petición.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.