Version: gesdinet/jwt-refresh-token-bundle 3.0.0 · Symfony 8.1.5 · PHP 8.4/8.5 · Doctrine ORM 3.6 (Postgres) · lexik/jwt-authentication-bundle 3.2.0
Summary
With both hash_tokens.enabled: true and reuse_detection.enabled: true, replaying a spent single-use refresh token is rejected (401) but never revokes the family: the detection never fires, with no error and no log.
Cause (read in the source)
AttachRefreshTokenOnSuccessListener.php:258 calls remember($refreshToken) with the model.
CacheSpentRefreshTokenRegistry::remember() keys the entry on sha256($refreshToken->getRefreshToken()). When hash_tokens is on, getRefreshToken() returns the stored hash (as the listener itself notes around line 304: "a manager that hashes the token keeps the hash on the model"), so the key is sha256(hash).
- On replay,
RefreshTokenAuthenticator.php:102 calls unknownTokenPresented($token) with the raw token from the request, and recall() looks up sha256(raw).
sha256(hash) !== sha256(raw) → nothing is ever recalled.
Reproduction (deterministic — one key changed, everything else identical)
Config: single_use: true, single_use_ttl_update: true, reuse_detection: { enabled: true, cache: cache.app }, Doctrine storage.
- login → RT1
- refresh with RT1 → 200, RT2
- refresh with RT1 again (replay) → 401 (expected, single use)
- refresh with RT2 (valid, never used) →
hash_tokens.enabled: false → 401 — family revoked ✅
hash_tokens.enabled: true → 200 — family alive ❌ (detection inert)
Verified twice independently (author + reviewer) on the same code base.
Expected
Either the registry keys on the raw token on both sides (the raw value is available in the request when the token is spent), or the two options are declared mutually exclusive at configuration time (like cache_pool vs reuse_detection already is), so the failure is loud instead of silent.
Workaround we chose
Keep hash_tokens: true and set reuse_detection.enabled: false explicitly (with a comment) until fixed. Happy to test a patch.
Version: gesdinet/jwt-refresh-token-bundle 3.0.0 · Symfony 8.1.5 · PHP 8.4/8.5 · Doctrine ORM 3.6 (Postgres) · lexik/jwt-authentication-bundle 3.2.0
Summary
With both
hash_tokens.enabled: trueandreuse_detection.enabled: true, replaying a spent single-use refresh token is rejected (401) but never revokes the family: the detection never fires, with no error and no log.Cause (read in the source)
AttachRefreshTokenOnSuccessListener.php:258callsremember($refreshToken)with the model.CacheSpentRefreshTokenRegistry::remember()keys the entry onsha256($refreshToken->getRefreshToken()). Whenhash_tokensis on,getRefreshToken()returns the stored hash (as the listener itself notes around line 304: "a manager that hashes the token keeps the hash on the model"), so the key issha256(hash).RefreshTokenAuthenticator.php:102callsunknownTokenPresented($token)with the raw token from the request, andrecall()looks upsha256(raw).sha256(hash) !== sha256(raw)→ nothing is ever recalled.Reproduction (deterministic — one key changed, everything else identical)
Config:
single_use: true,single_use_ttl_update: true,reuse_detection: { enabled: true, cache: cache.app }, Doctrine storage.hash_tokens.enabled: false→ 401 — family revoked ✅hash_tokens.enabled: true→ 200 — family alive ❌ (detection inert)Verified twice independently (author + reviewer) on the same code base.
Expected
Either the registry keys on the raw token on both sides (the raw value is available in the request when the token is spent), or the two options are declared mutually exclusive at configuration time (like
cache_poolvsreuse_detectionalready is), so the failure is loud instead of silent.Workaround we chose
Keep
hash_tokens: trueand setreuse_detection.enabled: falseexplicitly (with a comment) until fixed. Happy to test a patch.