Por qué implementé Refresh Token Rotation en vez de tokens de larga duración en go-iam-service
Cuando diseñé el sistema de autenticación de go-iam-service, una de las decisiones más importantes fue cómo manejar los refresh tokens. La tentación de usar tokens de larga duración (semanas o meses) es grande, especialmente cuando querés minimizar la fricción del usuario. Pero desde una perspectiva de seguridad, los refresh tokens de larga duración introducen un riesgo silencioso: una vez robados, el atacante tiene una ventana enorme para usarlos.
El problema con los refresh tokens estáticos
Un refresh token tradicional es una credencial persistente. El flujo típico:
- El usuario se autentica y recibe un access token (corto plazo, ej. 15 min) y un refresh token (largo plazo, ej. 30 días).
- Cuando el access token expira, el cliente usa el refresh token para obtener uno nuevo.
- El refresh token original sigue siendo válido hasta su expiración natural.
El problema: si un atacante intercepta el refresh token (malware, XSS, leak del lado del cliente), puede emitir nuevos access tokens indefinidamente hasta que el token expire. No hay forma de detectar el robo.
Refresh Token Rotation: cómo funciona
La rotación de refresh tokens cambia las reglas del juego:
- Cada vez que se usa un refresh token, el servidor lo invalida y emite uno nuevo.
- El refresh token anterior deja de ser válido inmediatamente.
- Si alguien intenta usar un token ya rotado, se dispara una alarma de breach: el servidor asume que el token fue robado y puede invalidar toda la familia de tokens.
Cliente Servidor
│ │
│── POST /auth/refresh ──────►│
│ Bearer: RT_old │
│ │
│◄── 200 OK ─────────────────│
│ { │
│ access_token: AT_new, │
│ refresh_token: RT_new │
│ } │
│ │
│ (RT_old queda invalidado) │
Implementación en go-iam-service
Lo implementé como un TokenFamily con los siguientes componentes:
1. Modelo de datos
type TokenFamily struct {
ID uuid.UUID
UserID uuid.UUID
CurrentHash string // SHA-256 del refresh token activo
PreviousHash string // SHA-256 del token anterior (para breach detection)
CreatedAt time.Time
RotatedAt time.Time
ExpiresAt time.Time
IsRevoked bool
}
La tabla en PostgreSQL almacena hashes de los tokens, nunca el token en sí. Esto asegura que un leak de la base de datos no exponga credenciales activas.
2. Breach detection
El algoritmo de rotación completa:
func (s *Service) Rotate(ctx context.Context, rawToken string) (*TokenPair, error) {
hash := sha256Hex(rawToken)
family, err := s.repo.FindByHash(ctx, hash)
if err != nil {
return nil, ErrInvalidToken
}
if family.IsRevoked {
return nil, ErrTokenRevoked
}
// Detección de breach: el hash coincide con PreviousHash,
// significa que alguien intenta reusar un token ya rotado
if family.PreviousHash == hash {
s.revokeFamily(ctx, family.ID) // Revoca toda la familia
s.alert(ctx, SecurityEvent{
Type: EventTokenBreach,
UserID: family.UserID,
FamilyID: family.ID,
Severity: SeverityCritical,
})
return nil, ErrPossibleTokenTheft
}
// Rotación normal
newToken := s.generateToken()
newHash := sha256Hex(newToken)
family.PreviousHash = family.CurrentHash
family.CurrentHash = newHash
family.RotatedAt = time.Now()
if err := s.repo.Update(ctx, family); err != nil {
return nil, fmt.Errorf("rotate: %w", err)
}
accessToken, err := s.issueAccessToken(ctx, family.UserID)
if err != nil {
return nil, fmt.Errorf("issue access: %w", err)
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: newToken,
}, nil
}
3. Rate limiting en el endpoint de refresh
Un atacante que robe un refresh token va a intentar usarlo apenas lo obtenga. Para mitigar el impacto, el endpoint POST /auth/refresh tiene rate limiting agresivo con Lua/Redis:
-- scripts/rate_limit_refresh.lua
local key = "rl:refresh:" .. KEYS[1] -- KEYS[1] = user_id
local window = 60 -- ventana de 60 segundos
local max = tonumber(ARGV[1]) or 5 -- máximo 5 rotaciones
local current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, window)
end
if current > max then
return 0 -- denegado
end
return 1 -- permitido
4. Observabilidad
Cada rotación emite métricas a Prometheus:
metrics.RefreshRotations.WithLabelValues("success").Inc()
metrics.RefreshTokenAge.WithLabelValues().Observe(time.Since(family.CreatedAt).Seconds())
Y un log estructurado para auditoría:
{
"level": "info",
"event": "token_rotated",
"user_id": "a1b2c3d4",
"family_id": "e5f6g7h8",
"rotations": 142,
"family_age_seconds": 2592000
}
Esto permitiría detectar patrones anómalos —por ejemplo, 142 rotaciones en 30 días para un solo usuario merecería una revisión.
Ventajas sobre tokens de larga duración
| Aspecto | Token estático | Token con rotación |
|---|---|---|
| Ventana de ataque | Hasta 30 días | Milisegundos (próximo uso legítimo) |
| Detección de robo | Imposible | Inmediata (breach alert) |
| Revocación | Manual o por expiración | Automática en toda la familia |
| Impacto de leak | Alto: acceso prolongado | Bajo: ventana mínima |
| UX | Sin fricción | Sin fricción (transparente) |
Consideraciones de implementación
Concurrencia
Si el cliente hace dos requests de refresh simultáneos, ambos podrían intentar usar el mismo token. Para evitar race conditions, la operación de rotación debe ser atómica. En go-iam-service uso una transacción serializable en PostgreSQL:
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
Si hay conflicto, la transacción falla y el cliente reintenta —el segundo intento encontrará el token ya rotado y recibirá un error, lo que lo obliga a volver a autenticarse.
Token family cleanup
Los refresh tokens tienen una expiración máxima (configurable, por defecto 90 días). Un job background corre cada hora eliminando familias expiradas:
func (s *Service) CleanupExpired(ctx context.Context) (int64, error) {
rows, err := s.db.ExecContext(ctx,
"DELETE FROM token_families WHERE expires_at < NOW()",
)
if err != nil {
return 0, fmt.Errorf("cleanup: %w", err)
}
return rows.RowsAffected()
}
Conclusión
La rotación de refresh tokens convierte una credencial estática y vulnerable en un mecanismo dinámico con detección de robo incorporada. No es más complejo de implementar que un sistema tradicional, y los beneficios de seguridad son enormes. En go-iam-service, esta fue una decisión de diseño temprana que pagó dividendos en simplicidad mental: sé que incluso si un token se ve comprometido, la ventana de explotación es mínima y tengo mecanismos para detectarlo y responder automáticamente.
Si estás diseñando un sistema de autenticación hoy, no uses refresh tokens estáticos. La rotación debería ser el default.