En FindMe (ubicación en tiempo real) la sesión tiene dos piezas: un access token corto que vive en memoria y un refresh token que vive en una cookie HttpOnly, fuera del alcance del JavaScript. El access token caduca en minutos; el refresh token dura semanas. Si alguien lo roba, tiene acceso todo ese tiempo, salvo que el servidor pueda notarlo.
Opaco, no JWT
El refresh token no es un JWT. Un JWT es válido hasta que expira y no se puede revocar antes sin añadir una lista de bloqueo. Este es un valor aleatorio de 256 bits y en la base solo se guarda su hash:
// SHA-256 y no bcrypt: el token ya es aleatorio de 256 bits, no hace falta
// un hash lento y sí una búsqueda por índice.
func hashRefreshToken(plain string) []byte {
sum := sha256.Sum256([]byte(plain))
return sum[:]
}Si se filtra la tabla, los hashes no sirven para nada.
Rotar para detectar
Cada vez que el cliente renueva la sesión, el servidor revoca el refresh token que recibió y emite otro. En uso normal, cada token se presenta una sola vez. Si llega uno que ya fue rotado, alguien tiene una copia vieja: o el atacante o la víctima. El servidor no puede saber quién es quién, así que revoca todas las sesiones de ese usuario. Los dos tienen que volver a entrar, y solo uno conoce la contraseña.
La carrera
Hay un detalle fácil de pasar por alto. Si la comprobación y la revocación son dos pasos (leer el token, ver que no esté revocado y marcarlo como revocado), queda una ventana: dos peticiones simultáneas con el mismo token pasan la comprobación antes de que cualquiera de las dos lo revoque, y las dos reciben un token nuevo. La detección fallaría justo cuando importa: cuando un atacante y la víctima renuevan a la vez.
En Cuadra la rotación es atómica. El UPDATE que revoca es condicional y va en la misma transacción que crea el sucesor. Si no afectó ninguna fila, otro ya lo había rotado:
affected, err := q.RevokeRefreshToken(ctx, params) // ... WHERE id = $2 AND revoked_at IS NULL
if err != nil {
return err
}
if affected == 0 {
return domain.ErrRefreshTokenReused
}
return createToken(ctx, q, next)Cuadra además agrupa los tokens en familias. Una reutilización revoca solo la familia de esa sesión, no las del resto de dispositivos del usuario, y se cuenta en una métrica para ver si alguna vez pasa de verdad.
El falso positivo que más duele
Con detección de reutilización, el peor enemigo es tu propio frontend. Si dos peticiones encuentran el access token caducado a la vez y cada una pide su renovación, la segunda presenta un token que la primera acaba de rotar. El servidor lo interpreta como robo y cierra la sesión sin que nadie haya hecho nada malo. En desarrollo, el doble montaje de StrictMode en React lo provoca siempre.
En el cliente de FindMe hay una sola renovación en curso, compartida por todas las peticiones. Si otra petición ya renovó, la que reintenta usa el token nuevo en vez de pedir otro. La renovación se adelanta un minuto al vencimiento, porque el WebSocket se autentica al conectar y una reconexión necesita un token vivo. Y un fallo de red no cierra la sesión: solo lo hace un 401.
Lecciones
- Refresh tokens opacos y hasheados: se pueden revocar y no sirven si se filtra la base.
- La rotación convierte la reutilización en una señal de robo.
- La comprobación y la revocación tienen que ser una sola operación atómica, o la detección tiene un hueco.
- Antes de activar la detección, asegúrate de que tu cliente nunca renueva dos veces en paralelo.