#2302·jwt-auth

[Security & Performance] Stop forcing Cache Tags for Blacklist / The "Double Probe" Bug

Author: macropay-solutionsCreated Jun 9, 2026Updated Jun 9, 2026

Description:

While fixing and optimizing the Tagged Cache architecture for Maravel-Framework (inherited from Laravel), we discovered a massive architectural trap in how this package handles the JWT blacklist.

By default, the package probes for cache tag support. If found, it forces flat, stateless 14-day JWT blacklist strings into a relational tagging matrix. This creates two major issues:

1. The Performance Bug: The "Double Probe" Storm In Tymon\JWTAuth\Providers\Storage\Illuminate, the driver executes an incredibly inefficient "Look Before You Leap" pattern on every single request:

It calls $this->cache->tags($this->tag); inside a try/catch in determineTagSupport() just to set a boolean.

It immediately calls it a second time in cache() to return the repository.

In vanilla Laravel, this forces the framework to instantiate TaggedCache and TagSet objects twice per API request. In optimized architectures, this forces redundant internal key sorting and metadata allocation twice per hit just to check one token.

2. The Security & Memory Trap A JWT blacklist is a flat array of isolated, independent strings. It does not need a relational hierarchy. Forcing millions of unique JWT IDs into Laravel's Tagged Cache creates severe vulnerabilities:

The Memory Leak (The Proof): Because Redis does not natively support TTLs on individual members of a collection, vanilla Laravel tracks tagged keys by pushing them into a Redis ZSET using the expiration timestamp as the score. Here is the exact underlying framework logic:

php
public function addEntry(string $key, ?int $ttl = null, $updateWhen = null)
{
    // It calculates the future timestamp...
    $ttl = is_null($ttl) ? -1 : Carbon::now()->addSeconds($ttl)->getTimestamp();

    foreach ($this->tagIds() as $tagKey) {
        // ...and saves the key into a ZSET using the timestamp as the score.
        $this->store->connection()->zadd($this->store->getPrefix() . $tagKey, $ttl, $key);
    }
}

When the JWT TTL expires, Redis naturally deletes the payload, but the string reference sits in the ZSET forever. Forcing a high-volume blacklist into tags leaks memory endlessly, requiring developers to run heavy ZSCAN cron jobs (cache:prune-stale-tags) just to clean up the garbage left behind.

LRU Eviction (Token Replay): If that cron job fails or lags behind API traffic, Redis will hit maxmemory. LRU eviction will then blindly delete active 14-day blacklist payloads to free up RAM. Logged-out tokens instantly become valid again.

Custom TTL Clipping: If a developer enforces a strict global TTL cap (e.g., 2 hours) on their tagged cache to optimize business data, it violently clips this package's 14-day token blacklist down to 2 hours, instantly exposing the API to Token Replay Attacks.

The Solution: Provide an Opt-Out Currently, developers are forced to write a custom subclass (JWTFlatStorage) overriding the provider to hardcode $this->supportsTags = false just to protect their servers.

Please add a simple config flag in config/jwt.php (e.g., 'use_tags' => false defaulting to true for backward compatibility) so we can cleanly decouple the stateless authentication layer from the relational tagging engine. Flat security keys belong in a flat keyspace.

For a full technical deep dive and the structural breakdown of why this coupling is dangerous, see: The Hidden Architecture Trap: Why Laravel’s Tagged Cache & JWT is a Security Time Bomb