Describe the bug
JWTUserProvider caches JWTUser instances by identifier in a plain PHP array $cache:
// Security/User/JWTUserProvider.php
public function loadUserByIdentifierAndPayload(string $identifier, array $payload): UserInterface
{
if (isset($this->cache[$identifier])) {
return $this->cache[$identifier]; // ? stale user returned, payload ignored
}
$class = $this->class;
return $this->cache[$identifier] = $class::createFromPayload($identifier, $payload);
}
In classic PHP-FPM each request gets a fresh process, so the cache is invisible. In long-running runtimes (FrankenPHP worker mode, Swoole, RoadRunner, etc.) the DI container, and therefore the JWTUserProvider instance, persists across requests. The cache accumulates entries and is never cleared.
Consequence: the first request that authenticates with a given username value populates the cache. Every subsequent request bearing the same username but different roles/claims receives the stale user object from the first request, completely ignoring the new JWT payload. This is a security issue: users may be granted or denied roles they should not have.
To Reproduce
- Deploy a Symfony app with
lexik_jwt user provider under FrankenPHP (or any long-running runtime).
- Issue JWT A ?
username: "guest", roles: ["ROLE_USER", "ROLE_ADMIN"].
- Make one authenticated request with JWT A ? user is cached as
guest with ROLE_ADMIN.
- Issue JWT B ?
username: "guest", roles: ["ROLE_USER"] (no admin).
- Make a request with JWT B ?
JWTUserProvider returns the cached user from step 3, with ROLE_ADMIN still present. The user from JWT B is incorrectly granted admin access.
Expected behavior
When a JWT carries a different payload than the one used to populate the cache, the provider should create a fresh user rather than returning the stale cached entry. At minimum, the cache should be invalidated between requests in long-running runtimes.
Proposed fix
Implement Symfony\Contracts\Service\ResetInterface on JWTUserProvider. Symfony's runtime component automatically calls reset() on all services that implement this interface between requests (this is already used throughout Symfony core for exactly this pattern):
use Symfony\Contracts\Service\ResetInterface;
final class JWTUserProvider implements PayloadAwareUserProviderInterface, ResetInterface
{
// ...
public function reset(): void
{
$this->cache = [];
}
}
This is the minimal, non-breaking change. The cache still provides its within-request deduplication benefit; it is simply cleared at the request boundary, which is the correct behaviour for a stateless JWT authenticator.
Environment
lexik/jwt-authentication-bundle: current main
- Runtime: FrankenPHP worker mode (reproducible with any long-running PHP runtime)
- Symfony: 7.4
Describe the bug
JWTUserProvidercachesJWTUserinstances by identifier in a plain PHParray $cache:In classic PHP-FPM each request gets a fresh process, so the cache is invisible. In long-running runtimes (FrankenPHP worker mode, Swoole, RoadRunner, etc.) the DI container, and therefore the
JWTUserProviderinstance, persists across requests. The cache accumulates entries and is never cleared.Consequence: the first request that authenticates with a given
usernamevalue populates the cache. Every subsequent request bearing the sameusernamebut different roles/claims receives the stale user object from the first request, completely ignoring the new JWT payload. This is a security issue: users may be granted or denied roles they should not have.To Reproduce
lexik_jwtuser provider under FrankenPHP (or any long-running runtime).username: "guest",roles: ["ROLE_USER", "ROLE_ADMIN"].guestwithROLE_ADMIN.username: "guest",roles: ["ROLE_USER"](no admin).JWTUserProviderreturns the cached user from step 3, withROLE_ADMINstill present. The user from JWT B is incorrectly granted admin access.Expected behavior
When a JWT carries a different payload than the one used to populate the cache, the provider should create a fresh user rather than returning the stale cached entry. At minimum, the cache should be invalidated between requests in long-running runtimes.
Proposed fix
Implement
Symfony\Contracts\Service\ResetInterfaceonJWTUserProvider. Symfony's runtime component automatically callsreset()on all services that implement this interface between requests (this is already used throughout Symfony core for exactly this pattern):This is the minimal, non-breaking change. The cache still provides its within-request deduplication benefit; it is simply cleared at the request boundary, which is the correct behaviour for a stateless JWT authenticator.
Environment
lexik/jwt-authentication-bundle: currentmain