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.