AFIQ SAZLAN

Laravel RateLimiter::clear() is not working for my endpoint throttle middleware

The Problem

I decided to implement throttle middleware to prevent misuse of an endpoint. However, I needed to customise it because I want to notifications to my Slack channel whenever this throttle is triggered.

So, I defined a custom rate limited in RouteServiceProvider and used in the route file.

// RouteServiceProvider
RateLimiter::for('api-upload', function (Request $request) {
    $userId = $request->user()->id;
    $this->sendSlackAlert();
    return Limit::perHour(10)->by($userId);
});

---

// Route file
Route::post('/upload', [UploadController::class, 'store'])
    ->middleware('throttle:api-upload');

When a user successfully performs a certain action without any abuse detected, I want to clear the throttle lock so the user may proceed without any hiccup. We cannot penalise a good and dedicated user, can we?

According to Laravel's documentation (Rate Limiting: Clearing Attempts ), it's pretty straightforward. Just use RateLimiter::clear($key).

So, I tried just that but it doesn't work somehow. I tried several variations with no success.

// These don't work
RateLimiter::clear('api-upload:' . $userId);
RateLimiter::clear('api-upload' . $userId);
RateLimiter::clear('api-upload|' . $userId);
RateLimiter::clear('api-upload', $userId);

In fact, I tried checking the attempts and still got no explaination.

// These don't work
RateLimiter::attempts('api-upload:' . $userId); // returns 0
RateLimiter::clear('api-upload' . $userId); // returns 0
RateLimiter::clear('api-upload|' . $userId); // returns 0
RateLimiter::clear('api-upload', $userId); // returns 0

The user is still getting 429 (Too Many Attempts) responses, but I just couldn't find the rate limit data anywhere.

The Hidden Truth

After checking Laravel's official repository, I found out something interesting.

Laravel's ThrottleRequests middleware automatically MD5 hashes named rate limiter keys.

From ThrottleRequests.php :

return $this->handleRequest(
    $request,
    $next,
    Collection::wrap($limiterResponse)->map(function ($limit) use ($limiterName) {
        return (object) [
            'key' => self::$shouldHashKeys ? md5($limiterName.$limit->key) : $limiterName.':'.$limit->key,
            // ...
        ];
    })->all()
);

So, if your user ID is 12345, the real cache key is actually md5('api-upload12345'), instead of api-upload12345.

Why RateLimiter::clear() Doesn't Work

The RateLimiter::clear() method doesn't handle MD5 un-hashing. The cleanRateLimiterKey() method only removes HTML entities - no MD5 hashing.

From RateLimiter.php :

public function clear($key)
{
    $key = $this->cleanRateLimiterKey($key);

    $this->resetAttempts($key);

    $this->cache->forget($key.':timer');
}

public function cleanRateLimiterKey($key)
{
    return preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key));
}

The Solution

Use the MD5 hash when clearing:

// This works!
RateLimiter::clear(md5('api-upload' . $userId));

Verifying in Tinker

// Check attempts (wrong way)
RateLimiter::attempts('api-upload12345'); // 0

// Check attempts (correct way) 
RateLimiter::attempts(md5('api-upload12345')); // 5

// Clear it
RateLimiter::clear(md5('api-upload12345'));

// Verify it's cleared
RateLimiter::attempts(md5('api-upload12345')); // 0

Helper Function

We can create a helper function to make our lives easier.

function clearNamedRateLimit($limiterName, $key) {
    RateLimiter::clear(md5($limiterName . $key));
}

// Usage
clearNamedRateLimit('api-upload', $userId);

Key Takeaway

When clearing named rate limiters, always use:

RateLimiter::clear(md5($limiterName . $key));

I wonder if I should attempt to create an issue to Laravel repository to accept hashing strategy for the RateLimiter clear function.

public function clear($key, $hash = false)
{
    if($hash){
        $key = md5($key);
    }

    $key = $this->cleanRateLimiterKey($key);

    $this->resetAttempts($key);
    $this->cache->forget($key.':timer');
}

// Usage
RateLimiter::clear('api-upload' . $userId, true);
RateLimiter::clear('api-upload' . $userId);