Cristhian Villegas
Cursos18 min read0 views

Curso JWT en Spring Boot 3 #2: Refresh Tokens y Rotación Segura

Curso JWT en Spring Boot 3 #2: Refresh Tokens y Rotación Segura

Parte 2 de 4 — Refresh Tokens

Refresh Tokens en Spring Boot

Fuente: Markus Spiske — Unsplash

Bienvenido a la Parte 2 del Curso JWT en Spring Boot 3. En el artículo anterior configuramos Spring Security con JWT desde cero: creamos el filtro de autenticación, el servicio para generar y validar tokens, y protegimos nuestros endpoints con roles.

Sin embargo, dejamos un problema importante sin resolver: nuestro access token tiene una vida útil corta (por ejemplo, 15 minutos) y cuando expira, el usuario se ve obligado a iniciar sesión de nuevo. Eso es una experiencia de usuario terrible.

En este artículo vas a aprender a implementar Refresh Tokens con rotación segura, de modo que la sesión del usuario se mantenga activa sin comprometer la seguridad.

Requisito previo: Este artículo asume que ya completaste la Parte 1 del curso, donde configuramos Spring Security con JWT. Si aún no lo has hecho, te recomiendo completar ese artículo primero.

Al finalizar este artículo serás capaz de:

  • Explicar la diferencia entre access tokens y refresh tokens
  • Implementar una entidad JPA para almacenar refresh tokens en base de datos
  • Crear un servicio completo de gestión de refresh tokens con rotación
  • Actualizar el flujo de autenticación para emitir ambos tokens
  • Proteger el refresh token con cookies HttpOnly
  • Detectar y manejar reutilización de tokens revocados

¿Por qué necesitamos Refresh Tokens?

Los access tokens JWT son stateless: el servidor no necesita guardar estado para validarlos. Esto los hace muy eficientes, pero también significa que no se pueden revocar una vez emitidos. Por eso es crítico que tengan una vida útil corta.

Considera este escenario:

  • El access token dura 15 minutos por seguridad
  • El usuario está trabajando en un formulario largo que le toma 20 minutos
  • Al enviar el formulario, recibe un error 401 Unauthorized
  • Tiene que iniciar sesión de nuevo y potencialmente perder su trabajo

Este es el problema clásico: seguridad vs experiencia de usuario.

La solución: Emitir un segundo token — el refresh token — con una vida útil más larga (días o semanas). Cuando el access token expira, el cliente usa el refresh token para obtener un nuevo par de tokens sin que el usuario tenga que volver a escribir sus credenciales.

El refresh token no viaja en cada petición HTTP como el access token, por lo que tiene una superficie de ataque mucho menor. Además, como lo almacenamos en base de datos, sí podemos revocarlo en cualquier momento.

Access Token vs Refresh Token

Antes de escribir código, veamos una comparación clara entre ambos tokens:

Característica Access Token Refresh Token
Propósito Autorizar peticiones a la API Obtener nuevos access tokens
Vida útil Corta (5–30 minutos) Larga (7–30 días)
Formato JWT (stateless) UUID opaco (stateful, en BD)
Almacenamiento cliente Memoria / header Authorization Cookie HttpOnly
Se envía en cada petición Sí (header Authorization) No (solo al endpoint /auth/refresh)
Revocable No (hasta que expire) Sí (se marca como revocado en BD)
Si se compromete Atacante tiene acceso temporal Se detecta por rotación y se revoca toda la familia
Importante: Nunca almacenes el refresh token en localStorage o sessionStorage. Estos son accesibles desde JavaScript y vulnerables a ataques XSS. Usa cookies HttpOnly con atributo SameSite.

Flujo Completo de Autenticación con Refresh Tokens

El flujo de autenticación con refresh tokens sigue estos pasos:

  1. Login: El usuario envía sus credenciales (POST /auth/login). El servidor valida las credenciales y genera un access token (JWT, 15 min) y un refresh token (UUID, 7 días). El access token se devuelve en el body de la respuesta y el refresh token se envía como cookie HttpOnly.
  2. Peticiones normales: El cliente envía el access token en el header Authorization: Bearer <accessToken>. El servidor valida el JWT y autoriza la petición.
  3. Access token expira: El cliente recibe un 401 Unauthorized.
  4. Renovación: El cliente envía una petición a POST /auth/refresh. La cookie HttpOnly con el refresh token se envía automáticamente. El servidor valida el refresh token, lo revoca, genera un nuevo par de tokens y devuelve el nuevo access token + nueva cookie con el refresh token rotado.
  5. Refresh token expira: Si el refresh token también expiró o fue revocado, el servidor devuelve 401 y el usuario debe iniciar sesión de nuevo.
text
1┌────────┐                          ┌────────────┐                    ┌──────┐
2│ Cliente│                          │  Servidor  │                    │  BD  │
3└───┬────┘                          └─────┬──────┘                    └──┬───┘
4    │  POST /auth/login                   │                              │
5    │  { email, password }                │                              │
6    ├────────────────────────────────────►│                              │
7    │                                     │  Valida credenciales         │
8    │                                     │  Genera accessToken (JWT)    │
9    │                                     │  Genera refreshToken (UUID)  │
10    │                                     ├─────────────────────────────►│
11    │                                     │  Guarda refreshToken         │
12    │  200 OK                             │◄─────────────────────────────┤
13    │  Body: { accessToken }              │                              │
14    │  Cookie: refreshToken (HttpOnly)    │                              │
15    │◄────────────────────────────────────┤                              │
16    │                                     │                              │
17    │  GET /api/recurso                   │                              │
18    │  Authorization: Bearer accessToken  │                              │
19    ├────────────────────────────────────►│                              │
20    │  200 OK { datos }                   │                              │
21    │◄────────────────────────────────────┤                              │
22    │                                     │                              │
23    │  (15 min después, token expirado)   │                              │
24    │  GET /api/recurso                   │                              │
25    ├────────────────────────────────────►│                              │
26    │  401 Unauthorized                   │                              │
27    │◄────────────────────────────────────┤                              │
28    │                                     │                              │
29    │  POST /auth/refresh                 │                              │
30    │  Cookie: refreshToken               │                              │
31    ├────────────────────────────────────►│                              │
32    │                                     │  Valida + revoca antiguo     │
33    │                                     │  Genera nuevo par            │
34    │                                     ├─────────────────────────────►│
35    │  200 OK                             │◄─────────────────────────────┤
36    │  Body: { accessToken }              │                              │
37    │  Cookie: nuevo refreshToken         │                              │
38    │◄────────────────────────────────────┤                              │

Entidad RefreshToken

Vamos a crear la entidad JPA que almacenará nuestros refresh tokens en la base de datos. Cada refresh token tiene un identificador único, está asociado a un usuario y tiene una fecha de expiración.

java
1package com.tuapp.security.entity;
2
3import jakarta.persistence.*;
4import java.time.Instant;
5import java.util.UUID;
6
7@Entity
8@Table(name = "refresh_tokens", indexes = {
9    @Index(name = "idx_refresh_token_token", columnList = "token", unique = true),
10    @Index(name = "idx_refresh_token_user_id", columnList = "user_id")
11})
12public class RefreshToken {
13
14    @Id
15    @GeneratedValue(strategy = GenerationType.IDENTITY)
16    private Long id;
17
18    @Column(nullable = false, unique = true)
19    private String token;
20
21    @ManyToOne(fetch = FetchType.LAZY)
22    @JoinColumn(name = "user_id", nullable = false)
23    private User user;
24
25    @Column(name = "expiry_date", nullable = false)
26    private Instant expiryDate;
27
28    @Column(nullable = false)
29    private boolean revoked = false;
30
31    @Column(name = "created_at", nullable = false, updatable = false)
32    private Instant createdAt;
33
34    @Column(name = "replaced_by")
35    private String replacedBy;
36
37    @PrePersist
38    protected void onCreate() {
39        this.createdAt = Instant.now();
40        if (this.token == null) {
41            this.token = UUID.randomUUID().toString();
42        }
43    }
44
45    // --- Constructores ---
46
47    public RefreshToken() {}
48
49    // --- Getters y Setters ---
50
51    public Long getId() { return id; }
52    public void setId(Long id) { this.id = id; }
53
54    public String getToken() { return token; }
55    public void setToken(String token) { this.token = token; }
56
57    public User getUser() { return user; }
58    public void setUser(User user) { this.user = user; }
59
60    public Instant getExpiryDate() { return expiryDate; }
61    public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; }
62
63    public boolean isRevoked() { return revoked; }
64    public void setRevoked(boolean revoked) { this.revoked = revoked; }
65
66    public Instant getCreatedAt() { return createdAt; }
67
68    public String getReplacedBy() { return replacedBy; }
69    public void setReplacedBy(String replacedBy) { this.replacedBy = replacedBy; }
70
71    public boolean isExpired() {
72        return Instant.now().isAfter(this.expiryDate);
73    }
74}

Observa los detalles importantes:

  • token: Es un UUID aleatorio, no un JWT. No necesita llevar información — solo es un identificador opaco
  • revoked: Flag booleano que permite revocar el token sin eliminarlo de la BD. Esto es útil para auditoría
  • replacedBy: Almacena el token que reemplazó a este. Permite rastrear la cadena de rotación y detectar reutilización
  • @Index: Índices en token y user_id para búsquedas eficientes

Ahora creamos el repositorio:

java
1package com.tuapp.security.repository;
2
3import com.tuapp.security.entity.RefreshToken;
4import com.tuapp.security.entity.User;
5import org.springframework.data.jpa.repository.JpaRepository;
6import org.springframework.data.jpa.repository.Modifying;
7import org.springframework.data.jpa.repository.Query;
8import org.springframework.stereotype.Repository;
9
10import java.time.Instant;
11import java.util.List;
12import java.util.Optional;
13
14@Repository
15public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
16
17    Optional<RefreshToken> findByToken(String token);
18
19    List<RefreshToken> findByUserAndRevokedFalse(User user);
20
21    @Modifying
22    @Query("UPDATE RefreshToken rt SET rt.revoked = true WHERE rt.user = :user AND rt.revoked = false")
23    int revokeAllByUser(User user);
24
25    @Modifying
26    @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now")
27    int deleteExpiredTokens(Instant now);
28}
Buena práctica: Agrega un job programado (@Scheduled) que ejecute deleteExpiredTokens periódicamente para limpiar tokens expirados y mantener la tabla ligera.

Servicio de Refresh Tokens

El RefreshTokenService encapsula toda la lógica de creación, validación y rotación de refresh tokens. Este es el componente central del sistema.

java
1package com.tuapp.security.service;
2
3import com.tuapp.security.entity.RefreshToken;
4import com.tuapp.security.entity.User;
5import com.tuapp.security.exception.TokenRefreshException;
6import com.tuapp.security.repository.RefreshTokenRepository;
7import org.springframework.beans.factory.annotation.Value;
8import org.springframework.stereotype.Service;
9import org.springframework.transaction.annotation.Transactional;
10
11import java.time.Duration;
12import java.time.Instant;
13import java.util.UUID;
14
15@Service
16public class RefreshTokenService {
17
18    private final RefreshTokenRepository refreshTokenRepository;
19    private final Duration refreshTokenDuration;
20
21    public RefreshTokenService(
22            RefreshTokenRepository refreshTokenRepository,
23            @Value("${app.security.refresh-token-duration:P7D}") Duration refreshTokenDuration) {
24        this.refreshTokenRepository = refreshTokenRepository;
25        this.refreshTokenDuration = refreshTokenDuration;
26    }
27
28    @Transactional
29    public RefreshToken createRefreshToken(User user) {
30        // Revocar todos los refresh tokens activos del usuario
31        refreshTokenRepository.revokeAllByUser(user);
32
33        RefreshToken refreshToken = new RefreshToken();
34        refreshToken.setUser(user);
35        refreshToken.setToken(UUID.randomUUID().toString());
36        refreshToken.setExpiryDate(Instant.now().plus(refreshTokenDuration));
37
38        return refreshTokenRepository.save(refreshToken);
39    }
40
41    @Transactional(readOnly = true)
42    public RefreshToken findByToken(String token) {
43        return refreshTokenRepository.findByToken(token)
44                .orElseThrow(() -> new TokenRefreshException("Refresh token no encontrado: " + token));
45    }
46
47    public RefreshToken verifyExpiration(RefreshToken token) {
48        if (token.isRevoked()) {
49            // Alguien intentó reutilizar un token revocado — posible robo
50            revokeDescendants(token);
51            throw new TokenRefreshException(
52                "El refresh token fue revocado. Se detectó posible reutilización. "
53                + "Todos los tokens de la familia han sido invalidados.");
54        }
55
56        if (token.isExpired()) {
57            token.setRevoked(true);
58            refreshTokenRepository.save(token);
59            throw new TokenRefreshException(
60                "El refresh token ha expirado. Por favor, inicia sesión de nuevo.");
61        }
62
63        return token;
64    }
65
66    @Transactional
67    public RefreshToken rotateRefreshToken(RefreshToken oldToken) {
68        // 1. Revocar el token antiguo
69        oldToken.setRevoked(true);
70
71        // 2. Crear nuevo token
72        RefreshToken newToken = new RefreshToken();
73        newToken.setUser(oldToken.getUser());
74        newToken.setToken(UUID.randomUUID().toString());
75        newToken.setExpiryDate(Instant.now().plus(refreshTokenDuration));
76
77        // 3. Enlazar el antiguo con el nuevo (para rastreo)
78        oldToken.setReplacedBy(newToken.getToken());
79        refreshTokenRepository.save(oldToken);
80
81        return refreshTokenRepository.save(newToken);
82    }
83
84    @Transactional
85    public void revokeAllUserTokens(User user) {
86        refreshTokenRepository.revokeAllByUser(user);
87    }
88
89    /**
90     * Si se detecta reutilización de un token revocado, revoca toda
91     * la cadena descendiente para invalidar al atacante.
92     */
93    @Transactional
94    private void revokeDescendants(RefreshToken compromisedToken) {
95        String nextToken = compromisedToken.getReplacedBy();
96        while (nextToken != null) {
97            RefreshToken descendant = refreshTokenRepository.findByToken(nextToken)
98                    .orElse(null);
99            if (descendant == null) break;
100
101            descendant.setRevoked(true);
102            refreshTokenRepository.save(descendant);
103            nextToken = descendant.getReplacedBy();
104        }
105    }
106}

Veamos los puntos clave del servicio:

  • createRefreshToken: Revoca todos los tokens anteriores del usuario antes de crear uno nuevo. Esto garantiza que solo haya un refresh token activo por usuario
  • verifyExpiration: Valida que el token no esté revocado ni expirado. Si detecta que alguien usa un token revocado, revoca toda la cadena descendiente como medida de seguridad
  • rotateRefreshToken: Implementa la rotación — el token antiguo se revoca y se crea uno nuevo. Se establece el enlace replacedBy para rastreo
  • revokeDescendants: Recorre la cadena de tokens y los revoca todos. Esto neutraliza a un atacante que haya robado un refresh token

Y la excepción personalizada:

java
1package com.tuapp.security.exception;
2
3import org.springframework.http.HttpStatus;
4import org.springframework.web.bind.annotation.ResponseStatus;
5
6@ResponseStatus(HttpStatus.UNAUTHORIZED)
7public class TokenRefreshException extends RuntimeException {
8
9    public TokenRefreshException(String message) {
10        super(message);
11    }
12}

Actualizando el AuthController

Ahora vamos a actualizar el controlador de autenticación para que emita ambos tokens al hacer login y maneje el flujo de renovación.

java
1package com.tuapp.security.controller;
2
3import com.tuapp.security.dto.AuthResponse;
4import com.tuapp.security.dto.LoginRequest;
5import com.tuapp.security.entity.RefreshToken;
6import com.tuapp.security.entity.User;
7import com.tuapp.security.service.JwtService;
8import com.tuapp.security.service.RefreshTokenService;
9import jakarta.servlet.http.HttpServletResponse;
10import jakarta.validation.Valid;
11import org.springframework.beans.factory.annotation.Value;
12import org.springframework.http.HttpHeaders;
13import org.springframework.http.ResponseCookie;
14import org.springframework.http.ResponseEntity;
15import org.springframework.security.authentication.AuthenticationManager;
16import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
17import org.springframework.security.core.Authentication;
18import org.springframework.web.bind.annotation.*;
19
20import java.time.Duration;
21
22@RestController
23@RequestMapping("/auth")
24public class AuthController {
25
26    private final AuthenticationManager authenticationManager;
27    private final JwtService jwtService;
28    private final RefreshTokenService refreshTokenService;
29    private final Duration refreshTokenDuration;
30
31    public AuthController(
32            AuthenticationManager authenticationManager,
33            JwtService jwtService,
34            RefreshTokenService refreshTokenService,
35            @Value("${app.security.refresh-token-duration:P7D}") Duration refreshTokenDuration) {
36        this.authenticationManager = authenticationManager;
37        this.jwtService = jwtService;
38        this.refreshTokenService = refreshTokenService;
39        this.refreshTokenDuration = refreshTokenDuration;
40    }
41
42    @PostMapping("/login")
43    public ResponseEntity<AuthResponse> login(
44            @Valid @RequestBody LoginRequest request,
45            HttpServletResponse response) {
46
47        Authentication authentication = authenticationManager.authenticate(
48                new UsernamePasswordAuthenticationToken(request.email(), request.password()));
49
50        User user = (User) authentication.getPrincipal();
51        String accessToken = jwtService.generateToken(user);
52        RefreshToken refreshToken = refreshTokenService.createRefreshToken(user);
53
54        // Enviar refresh token como cookie HttpOnly
55        addRefreshTokenCookie(response, refreshToken.getToken());
56
57        return ResponseEntity.ok(new AuthResponse(accessToken, "Bearer"));
58    }
59
60    @PostMapping("/refresh")
61    public ResponseEntity<AuthResponse> refreshAccessToken(
62            @CookieValue(name = "refreshToken") String refreshTokenValue,
63            HttpServletResponse response) {
64
65        RefreshToken oldToken = refreshTokenService.findByToken(refreshTokenValue);
66        refreshTokenService.verifyExpiration(oldToken);
67
68        // Rotar: revocar el antiguo y crear uno nuevo
69        RefreshToken newRefreshToken = refreshTokenService.rotateRefreshToken(oldToken);
70
71        // Generar nuevo access token
72        String accessToken = jwtService.generateToken(newRefreshToken.getUser());
73
74        // Actualizar la cookie con el nuevo refresh token
75        addRefreshTokenCookie(response, newRefreshToken.getToken());
76
77        return ResponseEntity.ok(new AuthResponse(accessToken, "Bearer"));
78    }
79
80    @PostMapping("/logout")
81    public ResponseEntity<Void> logout(
82            @CookieValue(name = "refreshToken", required = false) String refreshTokenValue,
83            HttpServletResponse response) {
84
85        if (refreshTokenValue != null) {
86            RefreshToken token = refreshTokenService.findByToken(refreshTokenValue);
87            refreshTokenService.revokeAllUserTokens(token.getUser());
88        }
89
90        // Eliminar la cookie
91        ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
92                .httpOnly(true)
93                .secure(true)
94                .path("/auth")
95                .maxAge(0)
96                .sameSite("Strict")
97                .build();
98        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
99
100        return ResponseEntity.noContent().build();
101    }
102
103    private void addRefreshTokenCookie(HttpServletResponse response, String tokenValue) {
104        ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenValue)
105                .httpOnly(true)
106                .secure(true)
107                .path("/auth")
108                .maxAge(refreshTokenDuration.toSeconds())
109                .sameSite("Strict")
110                .build();
111        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
112    }
113}

El DTO de respuesta es sencillo:

java
1package com.tuapp.security.dto;
2
3public record AuthResponse(String accessToken, String tokenType) {}
Nota sobre el DTO: Usamos un record de Java 17+ para el DTO de respuesta. Ya no devolvemos el refresh token en el body — viaja exclusivamente en la cookie HttpOnly.

Rotación de Tokens

La rotación de tokens es una técnica de seguridad esencial. Cada vez que se usa un refresh token, se revoca inmediatamente y se emite uno nuevo. Esto convierte al refresh token en un token de un solo uso.

¿Por qué es tan importante? Imagina este escenario:

  1. El usuario legítimo tiene el refresh token RT-1
  2. Un atacante roba RT-1 (por ejemplo, mediante un ataque de red)
  3. Sin rotación: Tanto el usuario como el atacante pueden usar RT-1 indefinidamente hasta que expire. El atacante tiene acceso silencioso durante semanas
  4. Con rotación: Si el usuario usa RT-1 primero, se genera RT-2 y RT-1 se revoca. Cuando el atacante intenta usar RT-1, el sistema detecta la reutilización y revoca toda la familia de tokens, incluyendo RT-2. El usuario tendrá que iniciar sesión de nuevo, pero el atacante queda bloqueado
text
1Escenario: Detección de reutilización de token
2
31. Login              → RT-1 (activo)
42. Atacante roba RT-1
53. Usuario usa RT-1   → RT-1 (revocado, replacedBy=RT-2), RT-2 (activo)
64. Atacante usa RT-1  → ¡ALERTA! RT-1 ya está revocado
7                       → Sistema revoca RT-2 también
8                       → Toda la familia invalidada
95. Usuario intenta    → RT-2 revocado → debe hacer login de nuevo
10   usar RT-2

La clave está en el campo replacedBy que agregamos a la entidad. Cuando el sistema detecta que un token revocado está siendo reutilizado, recorre la cadena de replacedBy y revoca todos los descendientes:

java
1// Dentro de verifyExpiration()
2if (token.isRevoked()) {
3    // Token revocado reutilizado — revocar toda la cadena descendiente
4    revokeDescendants(token);
5    throw new TokenRefreshException(
6        "Reutilización de token detectada. Familia de tokens invalidada.");
7}
8
9// revokeDescendants recorre la cadena:
10// RT-1 (revocado) → replacedBy → RT-2 (revocar) → replacedBy → RT-3 (revocar) → ...
Advertencia: Sin rotación de tokens, un refresh token robado le da al atacante acceso sostenido a la cuenta del usuario durante toda la vida útil del token (potencialmente semanas). La rotación es obligatoria en cualquier implementación de producción.

Almacenamiento Seguro en el Cliente

El lugar donde almacenas los tokens en el cliente es tan importante como la lógica del servidor. Veamos las opciones:

Método XSS CSRF Recomendado
localStorage Vulnerable Inmune No
sessionStorage Vulnerable Inmune No
Cookie normal Vulnerable Vulnerable No
Cookie HttpOnly + SameSite Inmune Protegido

La estrategia recomendada es:

  • Access token: En memoria (variable JavaScript). Se pierde al recargar la página, pero se renueva automáticamente con el refresh token
  • Refresh token: En una cookie HttpOnly con atributos de seguridad

Veamos la configuración de la cookie en detalle:

java
1ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenValue)
2        .httpOnly(true)    // No accesible desde JavaScript (protege contra XSS)
3        .secure(true)      // Solo se envía por HTTPS
4        .path("/auth")     // Solo se envía a endpoints bajo /auth
5        .maxAge(duration)  // Expira junto con el refresh token
6        .sameSite("Strict") // No se envía en peticiones cross-site (protege contra CSRF)
7        .build();

Cada atributo tiene un propósito de seguridad específico:

  • httpOnly(true): La cookie no es accesible desde document.cookie en JavaScript. Esto la hace invisible para scripts maliciosos inyectados por XSS
  • secure(true): La cookie solo se transmite por conexiones HTTPS. Previene que sea interceptada en conexiones no cifradas
  • path("/auth"): La cookie solo se envía en peticiones a rutas que empiezan con /auth. Esto reduce la superficie de ataque — la cookie no viaja en peticiones normales a la API
  • sameSite("Strict"): La cookie no se envía en peticiones que se originan desde otros dominios. Esto protege contra ataques CSRF. Si necesitas que funcione en iframes o redirecciones, puedes usar "Lax"
Configuración para desarrollo: En desarrollo local (HTTP, no HTTPS), necesitarás cambiar secure a false. Usa perfiles de Spring para manejar esta diferencia:
yaml
1# application.yml
2app:
3  security:
4    refresh-token-duration: P7D
5    cookie:
6      secure: true
7      same-site: Strict
8
9---
10# application-dev.yml
11spring:
12  config:
13    activate:
14      on-profile: dev
15
16app:
17  security:
18    cookie:
19      secure: false
20      same-site: Lax

Manejo de Errores y Casos Extremos

Una implementación robusta debe manejar correctamente varios escenarios de error. Vamos a crear un manejador global de excepciones para los errores relacionados con tokens:

java
1package com.tuapp.security.exception;
2
3import org.springframework.http.HttpStatus;
4import org.springframework.http.ProblemDetail;
5import org.springframework.web.bind.annotation.ExceptionHandler;
6import org.springframework.web.bind.annotation.RestControllerAdvice;
7
8import java.time.Instant;
9
10@RestControllerAdvice
11public class TokenExceptionHandler {
12
13    @ExceptionHandler(TokenRefreshException.class)
14    public ProblemDetail handleTokenRefreshException(TokenRefreshException ex) {
15        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
16                HttpStatus.UNAUTHORIZED, ex.getMessage());
17        problem.setTitle("Token de refresco inválido");
18        problem.setProperty("timestamp", Instant.now());
19        problem.setProperty("code", "REFRESH_TOKEN_INVALID");
20        return problem;
21    }
22}

Estos son los casos extremos que debes contemplar:

1. Refresh token no existe

El cliente envía un token que no existe en la base de datos. Esto puede suceder si la base de datos fue limpiada o si el token fue manipulado.

json
1{
2  "type": "about:blank",
3  "title": "Token de refresco inválido",
4  "status": 401,
5  "detail": "Refresh token no encontrado: abc-123-fake",
6  "code": "REFRESH_TOKEN_INVALID",
7  "timestamp": "2026-04-06T10:30:00Z"
8}

2. Refresh token expirado

El token existe pero su fecha de expiración ya pasó. Se marca como revocado y se pide al usuario que inicie sesión de nuevo.

3. Reutilización de token revocado

Este es el caso más crítico. Si un token revocado se intenta usar, significa que alguien robó un token antiguo. La respuesta del sistema es agresiva: revoca toda la cadena descendiente.

4. Peticiones concurrentes de refresh

Si el cliente envía dos peticiones de refresh simultáneas con el mismo token, la primera tendrá éxito y la segunda recibirá un error de token revocado. Esto puede causar un falso positivo en la detección de reutilización.

Solución para peticiones concurrentes: Implementa un período de gracia corto (por ejemplo, 10 segundos) durante el cual un token recién revocado aún es aceptado. Otra opción es usar un mutex en el cliente para serializar las peticiones de refresh.
java
1// Período de gracia para tokens recién revocados (evita falsos positivos)
2public RefreshToken verifyExpiration(RefreshToken token) {
3    if (token.isRevoked()) {
4        // Verificar si fue revocado hace menos de 10 segundos (petición concurrente)
5        if (token.getReplacedBy() != null) {
6            RefreshToken replacement = refreshTokenRepository
7                    .findByToken(token.getReplacedBy()).orElse(null);
8            if (replacement != null
9                    && !replacement.isRevoked()
10                    && replacement.getCreatedAt().plusSeconds(10).isAfter(Instant.now())) {
11                // Período de gracia: devolver el token de reemplazo
12                return replacement;
13            }
14        }
15        revokeDescendants(token);
16        throw new TokenRefreshException("Reutilización de token detectada.");
17    }
18
19    if (token.isExpired()) {
20        token.setRevoked(true);
21        refreshTokenRepository.save(token);
22        throw new TokenRefreshException("El refresh token ha expirado.");
23    }
24
25    return token;
26}

5. Limpieza programada de tokens expirados

Con el tiempo, la tabla de refresh tokens crecerá. Agrega una tarea programada para limpiar tokens expirados:

java
1@Component
2@RequiredArgsConstructor
3public class TokenCleanupTask {
4
5    private final RefreshTokenRepository refreshTokenRepository;
6
7    @Scheduled(cron = "0 0 2 * * ?") // Todos los días a las 2:00 AM
8    @Transactional
9    public void purgeExpiredTokens() {
10        int deleted = refreshTokenRepository.deleteExpiredTokens(Instant.now());
11        if (deleted > 0) {
12            log.info("Tokens expirados eliminados: {}", deleted);
13        }
14    }
15}

Próximos Pasos

En este artículo implementamos un sistema completo de refresh tokens con rotación y detección de reutilización. Nuestro flujo de autenticación ahora es mucho más robusto:

  • Los access tokens tienen vida corta (15 minutos) para minimizar el riesgo
  • Los refresh tokens permiten mantener la sesión activa sin comprometer la seguridad
  • La rotación de tokens detecta y neutraliza el robo de tokens
  • Las cookies HttpOnly protegen los tokens de ataques XSS

Sin embargo, aún tenemos un problema: si un access token es robado, sigue siendo válido hasta que expire. No hay forma de revocarlo antes de tiempo porque es stateless.

En la Parte 3 del curso resolveremos este problema implementando JTI (JWT ID) y blacklisting con Redis. Aprenderás a:
  • Agregar un identificador único (JTI) a cada access token
  • Almacenar tokens revocados en Redis con TTL automático
  • Verificar la lista negra en cada petición sin impactar el rendimiento
  • Implementar logout real que invalide tanto el access token como el refresh token

El código completo de este artículo está disponible en el repositorio del curso. Nos vemos en la Parte 3.

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles

Stay updated

Get notified when I publish new articles. No spam, unsubscribe anytime.