After a few days of struggling I have found a few-lines long solution to the problem of how to show ReCaptcha after a few hits on the endpoint. It is useful for for instance for payment gateway integration, where this way you make sure attacker is not abusing your app to find out which card numbers are real and which not. Requiring a ReCaptcha after a few successive hits in a short amount of time greatly reduces this attack vector. Let's take a look, assuming Laravel 8:

The middleware class simply overrides the handle method of the standard \Illuminate\Routing\Middleware\ThrottleRequests throttling middleware:

<?php

namespace App\Middleware;

use App\Http\Middleware\MyReCaptchaMiddleware;
use Closure;
use Illuminate\Routing\Middleware\ThrottleRequests;

class MyReCaptchaThrottleRequests extends ThrottleRequests
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  int|string  $maxAttempts
     * @param  float|int  $decayMinutes
     * @param  string  $prefix
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
     */
    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
    {
        return $this->handleRequest(
            $request,
            $next,
            [
                (object) [
                    'key' => $prefix.$this->resolveRequestSignature($request),
                    'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts),
                    'decayMinutes' => $decayMinutes,
                    'responseCallback' => fn() => app(MyReCaptchaMiddleware::class)->handle($request, $next),
                ],
            ]
        );
    }

The above assumes that you already have your own MyReCaptchaMiddleware up and running, we won't go into details of it here. There are many guides already. It will be called via the responseCallback, which in the parent function is normally null. Most magic happens basically at this very line.

Next add the route middleware inside the app/Http/Kernel.php like this:

protected $routeMiddleware = [
    // ...
    'throttle_recaptcha' => MyReCaptchaThrottleRequests::class,
];

The last step is to actually assign the throttle_recaptcha middleware to the route:

Route::get('/endpoint', function () {
    //
})->middleware('throttle_recaptcha');

Note it is possible to add the maxAttempts optional parameter:

Route::get('/endpoint', function () {
    //
})->middleware('throttle_recaptcha:10');

And even a decayMinutes, as a second parameter:

Route::get('/endpoint', function () {
    //
})->middleware('throttle_recaptcha:10,2');

The above will require ReCaptcha verication on our endpoint after 10 hits and will persist Requiring it for 2 straight minutes afterward. That's it. Note that this will still rate-limit the actual endpoint for all it's consumers, depending on your needs, it might need more tweaking to throttle the endpoint per-user! This can be easily done by overriding resolveRequestSignature() method as well.

Named limiters

There is a paragraph I purposefully removed from the top parent handle() function, as you might see. For the record, it's this one:

if (is_string($maxAttempts)
    && func_num_args() === 3
    && ! is_null($limiter = $this->limiter->limiter($maxAttempts))) {
    return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter);
}

The removed code serves for a useful feature, loosely called named limiters where in the place of $maxAttempts parameter is the string with the name of the throttle rate limiter:

Route::middleware(['throttle:my_rate_limiter'])->group(function () {
    Route::post('/endpoint', function () {
        //
    });
});

The above would use the rate limiter definition like this one:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

protected function configureRateLimiting()
{
    RateLimiter::for('my_rate_limiter', function (Request $request) {
        return Limit::perMinute(1000);
    });
}

But since we rather use the provided $maxAttempts and $decayMinutes middleware parameters of the throttle middleware only for their intended purpose, we can safely omit that block. Also, for now hopefully obvious reasons, it is not possible to use parameters with named limiters.

If you intend to use named limiters also with the throttle_recaptcha middleware, you can keep that block in. It won't hurt at all. I found it's code confusing as $maxAttempts parameter is definitely not a good name for the actual max attempts as well as the name of the rate limiter. I suspect that the named limiters feature was probably added in as afterthought. I did not want to confuse my colleagues during the code review further, so I omitted it. But it is a useful feature nevertheless, so keep that in mind. Enjoy!

Rate limiter headers

There are two headers that are added to the rate limited endpoints:

  • X-RateLimit-Limit
  • X-RateLimit-Remainig

It's the latter I am using to determine when to display the ReCaptcha on the front-end. Simply, if the remaining hits shown in the X-RateLimit-Remainig response header equals to one, I know that all the successive requests will require ReCaptcha token, so that is the exact time to make user (or the automated bot) pass the test, obtaining the token and attaching it to legitimate successive requests.

As a side note, now the X-RateLimit-Remainig will always see two hits, due to the issue discussed here which is unfortunate, but can be worked around.

Enjoy!