Upgrade if you have hash_tokens enabled. The value stored for a refresh token was accepted as the token itself, so being able to read the table was enough to refresh with any row in it.
Security: the stored hash was accepted as the refresh token
HashedRefreshTokenManager::get() hashes what is presented and looks that up. A value that is already a stored hash cannot match there, because that lookup asks for the hash of a hash, so it fell through to the fallback that keeps tokens issued before hashing was turned on working. That fallback looks the presented value up as it is, and the stored value matches the row holding it. save() then saw the sha256$ prefix and left the row alone, so the same value went on working indefinitely, and with single_use each rotated row was as usable as the one before it.
Reading the table is the one thing hashing exists to survive, so this took the point out of the option entirely. A value in the stored format is now refused before the fallback runs, and not looked up at all: the sha256$ prefix is reserved, and no client is ever given a value carrying it.
Affects 2.2.0, 2.2.1, 2.2.2 and 3.0.0 with hash_tokens enabled. It is not reachable without read access to the stored rows. 2.3.0 carries the same fix for the 2.x line.
If you cannot upgrade right away, this closes the only way in, on every affected version:
gesdinet_jwt_refresh_token:
hash_tokens:
enabled: true
accept_stored_in_the_clear: falseThat also stops accepting tokens issued before hashing was turned on, so any session still holding one is signed out.
Reported privately by Rathnadeepan (HotWater), who also proposed the fix taken here. A GitHub security advisory is being filed.
Clearing expired tokens without reading them first
revokeAllInvalid() and revokeAllInvalidBatch() hydrate every token they delete, because they hand them back and the object manager wants the objects to remove them. That is what lets gesdinet:jwt:clear list what it revoked and what raises the life-cycle events an application may be listening for, and it costs one object per dead row. Clearing a table that has grown for months is the one thing the command was bad at.
Model\ExpiredRefreshTokenManagerInterface::revokeAllExpired() sends a single statement instead, so what it costs no longer grows with the backlog:
use Gesdinet\JWTRefreshTokenBundle\Model\ExpiredRefreshTokenManagerInterface;
public function __construct(
private ExpiredRefreshTokenManagerInterface $refreshTokenManager,
) {
}
public function clearOut(): int
{
return $this->refreshTokenManager->revokeAllExpired();
}and --direct runs it from the command line:
php bin/console gesdinet:jwt:clear --directThe trade is that the tokens are only counted. Nothing is listed with -v, no life-cycle events are raised, and --batch-size has nothing left to do, which is why it is a flag rather than the new behaviour. Without it the command works exactly as before.
Available with the ORM, ODM and DBAL backends. A repository of your own gains it by implementing Doctrine\DeleteExpiredRefreshTokenRepositoryInterface, and the manager says which interface is missing rather than calling a method that is not there.
The implementation is @Rainrider's, from #432, which he withdrew.
Fixed
reuse_detectionrecorded a spent token under the value the model carries, which is the hash whenhash_tokensis on, while every lookup asks for the value the client sent. Nothing was ever recalled, so the detection was inert, with no error and no log (#433)cache_pool,block_jwts_on_revocation,rate_limiterandreuse_detectioneach took a service id that was referenced as a%parameter%placeholder inside a service id, which Symfony never resolves. Turning any of the four on left the container unable to compile (#434)
Changed
- Requires Symfony 8.1. Every
symfony/http-foundation8.0.x is affected by CVE-2026-108082, fixed only in 8.1.7, so there is no 8.0 left to support
No BC break, and no schema change.