Rajtík's blog

← Back to home

Security 6 min read

Why Fortify verifies the captcha twice in one request

It is not a double form submit. With 2FA enabled, Fortify calls the authenticateUsing callback twice in one request and a single-use captcha token cannot survive that.

The captcha reported "invalid token" on every login. Yet I know it works as it should, the widget turned green, the captchaapi_response field sat in the request. But the server log had two calls to /verify with the same value - and the second one returned that the token was invalid because it had already been used.

First instinct: is the form being submitted twice? But /login has only one POST. This trap is called Fortify. The explanation is in its login pipeline.

It follows on from One attestation, one submit, where I cover why the single use of the token is held by the captchaapi.eu server and not by the integrator. Here it is about how that single use collides with Fortify.


The symptom

Login runs through Fortify with custom authentication via Fortify::authenticateUsing(). The captcha is checked inside the callback, before the password is touched:

Fortify::authenticateUsing(function (Request $request) {
    if (! Captchaapi::isFake() && Captchaapi::enabled()) {
        $request->validate([
            'captchaapi_response' => ['required', 'string', 'captcha'],
        ]);
    }

    $user = User::query()->where('email', $request->string('email')->lower())->first();

    if ($user && Hash::check((string) $request->input('password'), $user->password)) {
        return $user;
    }

    return null;
});

One POST to /login, one token, and still two /verify calls. The token is single-use - the server claims it on the first verification and rejects a second attempt with the same value. That breaks the login of a user who solved the captcha correctly.


It is not a double submit, it is a double callback call

The key is how many times Fortify runs that authenticateUsing callback. With 2FA enabled? Twice. The login pipeline (AuthenticatedSessionController::loginPipeline) looks like this:

return (new Pipeline(app()))->send($request)->through(array_filter([
    config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,
    config('fortify.lowercase_usernames') ? CanonicalizeUsername::class : null,
    Features::enabled(Features::twoFactorAuthentication()) ? RedirectsIfTwoFactorAuthenticatable::class : null,
    AttemptToAuthenticate::class,
    PrepareAuthenticatedSession::class,
]));

The pipeline reaches for the same callback twice - each time for a different reason.

1. RedirectIfTwoFactorAuthenticatable needs the user to know whether to send them to the 2FA challenge. It gets the user by calling your callback:

protected function validateCredentials($request)
{
    if (Fortify::$authenticateUsingCallback) {
        return tap(call_user_func(Fortify::$authenticateUsingCallback, $request), ...);
    }
    // …
}

2. AttemptToAuthenticate a few lines further on logs the user in, so it calls the callback again:

protected function handleUsingCustomCallback($request, $next)
{
    $user = call_user_func(Fortify::$authenticateUsingCallback, $request);
    // …
    $this->guard->login($user, $request->boolean('remember'));
    return $next($request);
}

Two independent calls of the same callback in one request. Each runs $request->validate([...'captcha']), so the ValidCaptcha rule sends to /verify twice. The second call hits the already claimed token and fails.


Why the captcha does not survive this but the password does

The password check is idempotent. Hash::check() with the same inputs returns the same thing the second time. Running it twice bothers nobody.

Captcha verification is not idempotent. The token is single-use on purpose - one solved challenge may let through exactly one submission. That is the whole point of replay protection. But the server reads "one submit" as "one /verify", and Fortify sends two. From the server's point of view the second call looks exactly like an attacker recycling someone else's token.

So the irony: the more honestly the server enforces single use, the more reliably Fortify knocks it over.


The fix: store the result in request attributes

The second /verify call must not run, so the result of /verify is stored in the request attributes:

$memoKey = $this->memoKey($value);

if ($this->isMemoized($memoKey)) {
    return; // already verified in this request
}

// … /verify call …

$this->memoize($memoKey); // only after success

The memo lives in request()->attributes:

private function isMemoized(string $memoKey): bool
{
    if (! app()->bound('request')) {
        return false;
    }

    return (bool) request()->attributes->get($memoKey, false);
}

private function memoize(string $memoKey): void
{
    if (! app()->bound('request')) {
        return;
    }

    request()->attributes->set($memoKey, true);
}

The first pass calls /verify and remembers the success. The second finds it in the request's memory and returns without a network call. The token stays single-use on the server where it should be.


Why not a persistent cache

A tempting variant is to store the verdict in Redis or the database. Do not do it! Two independent requests from an attacker with the same token would both pass from the cache and the replay protection the server holds would be worthless. And that is exactly what a single-use token is supposed to catch.

That is why the memo lives in request()->attributes and nowhere else:

  • it applies to a single HTTP request only
  • it disappears at the end of it
  • it does not affect verification in the next request

That is the fundamental difference from "two submits". If it were two HTTP requests, the memo would not help and you would be dealing with a security problem. But it is two calls inside one request - and there the memo fits exactly. It fixes the double call, but it does not survive into the next attempt.


When it does not show up

That first pipe in the pipeline is conditional:

Features::enabled(Features::twoFactorAuthentication()) ? RedirectsIfTwoFactorAuthenticatable::class : null,

Without 2FA the pipe drops out, the callback is called only once and the captcha is verified only once. The bug appears only when you enable Features::twoFactorAuthentication() in config/fortify.php. Then RedirectIfTwoFactorAuthenticatable starts appearing in the pipeline and the second callback call is born.

And one last trap: the integrators' tests will not catch it if they bypass the captcha in tests with a fake bypass. The second /verify is never really sent, the token is not claimed and everything is green. It fails only in production on login.


TL;DR

  • It is not a "double submit". One POST arrives at /login.
  • Fortify with 2FA enabled calls the authenticateUsing callback twice in one request: once in RedirectIfTwoFactorAuthenticatable, once in AttemptToAuthenticate.
  • The password survives that (idempotent), the captcha does not - a single-use token fails on the second /verify.
  • The fix: memoization in request()->attributes. The first pass verifies, the second is taken from the request's memory.
  • Not a persistent cache - that would let the token's uniqueness be bypassed across requests.
  • Without 2FA the bug does not show up and a fake bypass in tests hides it. You find out only in production.