Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

JWTUserProvider in-memory cache causes stale roles in long-running PHP runtimes (FrankenPHP) #1307

Copy link
Copy link
@ambroisemaupate

Description

@ambroisemaupate
Issue body actions

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

  1. Deploy a Symfony app with lexik_jwt user provider under FrankenPHP (or any long-running runtime).
  2. Issue JWT A ? username: "guest", roles: ["ROLE_USER", "ROLE_ADMIN"].
  3. Make one authenticated request with JWT A ? user is cached as guest with ROLE_ADMIN.
  4. Issue JWT B ? username: "guest", roles: ["ROLE_USER"] (no admin).
  5. 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
Reactions are currently unavailable

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      Morty Proxy This is a proxified and sanitized view of the page, visit original site.