Saltar a contenido

Refresh Token

El refresh token permite obtener nuevos access tokens sin que el usuario vuelva a autenticarse. Tiene una vida más larga que el access token y se usa exclusivamente en el token endpoint, nunca en llamadas a APIs.

Ciclo de vida

sequenceDiagram
    actor U as Usuario
    participant C as Cliente
    participant K as Keycloak

    U->>C: Login inicial
    C->>K: POST /token (authorization_code + PKCE)
    K-->>C: access_token (5 min) + refresh_token (30 min)

    Note over C,K: ... 5 minutos después ...

    C->>C: Detecta access_token expirado (exp < now)
    C->>K: POST /token\ngrant_type=refresh_token\nrefresh_token=<token>
    K->>K: Validar refresh_token\nVerificar no revocado\nVerificar sesión activa
    K-->>C: nuevo access_token (5 min) + nuevo refresh_token (30 min)
    Note right of K: Refresh Token Rotation:\nel anterior queda invalidado

Obtener nuevo access token con curl

KC_URL="https://auth.example.com"
REALM="realm-internal"
CLIENT_ID="partner-portal-app"
REFRESH_TOKEN="<refresh_token_guardado>"

curl -s -X POST \
  "${KC_URL}/realms/${REALM}/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "client_id=${CLIENT_ID}" \
  -d "refresh_token=${REFRESH_TOKEN}" | jq .

Configuración en Keycloak

Realm Settings → Tokens:
  Access Token Lifespan:           300 s  (5 minutos)
  Client Session Idle:            1800 s  (30 minutos)
  Client Session Max:            28800 s  (8 horas)
  Refresh Token Max Reuse:           0    ← Rotation: cada uso invalida el anterior
  Offline Session Idle:          2592000 s (30 días) ← Para offline tokens

Refresh Token Rotation

Con Refresh Token Max Reuse: 0, cada vez que el cliente usa el refresh token obtiene uno nuevo y el anterior queda inmediatamente invalidado. Esto previene el robo silencioso de refresh tokens: si un atacante usa el token robado, el usuario legítimo recibirá un error al intentar refrescarlo.

Implementación en Java con Spring WebFlux

@Component
@RequiredArgsConstructor
public class TokenManager {

    private final WebClient keycloakClient;
    private final ObjectMapper objectMapper;

    private volatile TokenPair currentTokens;

    /**
     * Obtiene un access token válido.
     * Si ha expirado, lo renueva automáticamente con el refresh token.
     */
    public Mono<String> getValidAccessToken() {
        if (currentTokens == null) {
            return Mono.error(new IllegalStateException("No autenticado"));
        }

        if (currentTokens.isAccessTokenValid()) {
            return Mono.just(currentTokens.accessToken());
        }

        return refreshTokens();
    }

    private Mono<String> refreshTokens() {
        return keycloakClient.post()
                .uri("/protocol/openid-connect/token")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .bodyValue("grant_type=refresh_token"
                        + "&client_id=" + clientId
                        + "&refresh_token=" + currentTokens.refreshToken())
                .retrieve()
                .onStatus(status -> status.value() == 400, response ->
                        // refresh_token expirado o revocado → re-autenticar
                        Mono.error(new SessionExpiredException("Sesión expirada, iniciar sesión de nuevo")))
                .bodyToMono(TokenResponse.class)
                .doOnNext(response -> currentTokens = TokenPair.from(response))
                .map(TokenResponse::accessToken);
    }
}

Almacenamiento seguro del refresh token

Tipo de cliente Almacenamiento recomendado Evitar
BFF (server-side) Sesión HTTP server-side (en memoria o Redis) Nunca en la respuesta al browser
SPA (browser) Cookie HttpOnly + Secure + SameSite=Strict localStorage o sessionStorage
App móvil nativa Secure Storage (Keychain iOS / Keystore Android) SharedPreferences o UserDefaults
CLI/Desktop Sistema de credenciales del OS Archivos en disco sin cifrar

El refresh token en localStorage es una vulnerabilidad crítica

Un XSS puede robar el refresh token de localStorage y usarlo para obtener nuevos access tokens indefinidamente. Usa siempre cookies HttpOnly o almacenamiento server-side.

Revocación del refresh token

# Revocar un refresh token específico
curl -s -X POST \
  "${KC_URL}/realms/${REALM}/protocol/openid-connect/revoke" \
  -d "client_id=${CLIENT_ID}" \
  -d "token=${REFRESH_TOKEN}" \
  -d "token_type_hint=refresh_token"

Al hacer logout, siempre revoca el refresh token para invalidar la sesión completamente.