CWE-362 · CWE-367 · OWASP A07:2021 · Identification and Authentication Failures

New to the series? Start at Post 1: Getting Started to set up the lab. Already have a clone? Run git fetch origin && git pull to pull the latest changes.
ArtisanBreach's admin login has a five-attempts-per-minute lockout. It works fine if you send your guesses one at a time like a polite person. It does nothing at all if you send twenty of them at once. The lockout trait checks "have you failed too many times" before it checks the password, then only updates the failure count after the password check finishes, and that password check takes real time (bcrypt, twelve rounds). Fire a burst of concurrent requests and the ones that reach the check before the first write all read the same stale counter. They slip through the same open door at the same time. In one twenty-request burst against this lab, the counter that's supposed to cap out at five ended up at seven. In roughly a third of our test runs, the real admin password rode along in the same burst and logged straight in, no lockout, no delay, no wordlist science. The code isn't missing a security control here. The security control is right there, reads correctly, and loses a foot race every time enough requests show up together.

Where This Sits in the Kill Chain

Every other way into dvla-admin so far has needed something extra: a leaked .env and a forged cookie (Post 2), a magic-hash quirk on a legacy account (Post 3), a public contact form and a mail job (Post 12). This one needs none of that. It targets the login form directly, the same login form everyone assumes is protected because there's a visible lockout message sitting right there in the code.

That's what makes it worth its own post this late in the series. It's not a new room in the house, it's a second key to the front door, one that works because the lock's own bookkeeping can't keep up with itself. Land here and you're standing in the exact same php-fpm worker, same filesystem, same mounted docker.sock, as Post 2's deserialization RCE. Different route in, identical blast radius once you're through.

Race condition diagram

Four separate front doors into the same room now. What that says about a real assessment: a target rarely falls to one clean exploit chain, it falls because three or four unrelated small mistakes all happen to open onto the same internal surface, and an attacker only needs to find one.

Vulnerability Classification

CWE CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization ("Race Condition"). The specific shape of it is CWE-367, Time-of-check Time-of-use, and the practical result is CWE-799, Improper Control of Interaction Frequency. The rate limit exists, it just stops limiting under load.
OWASP Top 10 A07:2021 Identification and Authentication Failures. OWASP's own description of this category calls out "permits brute force or other automated attacks" and "does not limit failed login attempts" by name. This is that, just triggered by concurrency instead of a missing check.
Why it's easy to miss in review Nothing about the code looks wrong on a read-through. There's a max attempts limit, a lockout check, a friendly "too many attempts, try again in N seconds" message. Every box a reviewer looks for is ticked. The bug isn't in what the code checks, it's in the order two operations happen relative to a slow one sitting between them. That's not something you spot by reading top to bottom once.
Authentication required None. This targets /login itself, the public form every visitor sees. There's no prior foothold and no account; you only need the anonymous session cookie and CSRF token the login page hands out. The control being bypassed is the one that's supposed to slow down exactly this kind of anonymous guessing.
Where it actually happens Entirely inside dvla-admin, the php-fpm container. No queue worker, no Redis, no second container involved. That's part of what makes it boring to set up and dangerous to ignore: it's the plainest possible HTTP endpoint, doing exactly what its own code says, just faster than the code expects.
Severity note High. On its own this is a control failure, brute force protection that doesn't hold under concurrency, so its real-world severity rides on whatever password sits behind the account. Against a weak or reused password it's a direct path to admin. Against a strong one it still turns a supposedly-throttled endpoint into an unthrottled one, which is exactly the property a lockout exists to prevent.

Race Conditions in Thirty Seconds

A race condition happens whenever code reads a piece of shared state, does something based on what it read, and then writes back to that same state, with any gap in between where another request could sneak in and do the same thing. The classic example is a bank balance: check that there's $100 available, then process the withdrawal, then subtract $100. Fire two withdrawal requests for $100 against a $100 balance at the same moment, and if the check for both happens before either one writes back, both succeed. The balance goes negative and nobody wrote a line of code that allowed that on paper.

A login lockout is the exact same shape, just counting failures instead of money. Check "has this key failed five times", do the (slow) work of verifying the password, then update the failure count. The gap between the check and the update is the race window, and how big that window is depends entirely on how slow the work in the middle is. A cheap comparison gives you almost no window. A bcrypt hash comparison, deliberately slow by design, hands you a window measured in hundreds of milliseconds, plenty of time for a server to accept a dozen more requests before the first one finishes.

This app's order: check, work, then write
if (attempts >= 5) reject();

$ok = password_verify($guess, $hash);  // slow, ~100-300ms

if (!$ok) attempts++;
The fix: check, write, then work
if (attempts >= 5) reject();

attempts++;  // reserve the slot first, before anything slow

$ok = password_verify($guess, $hash);

if ($ok) attempts = 0;

That's pseudocode to make the shape obvious. The real bug lives in the unmodified AuthenticatesUsers trait that this app's LoginController pulls in.

The Vulnerable Code

ArtisanBreach's admin login controller is about as plain as Laravel controllers get. It doesn't write its own throttling logic, it pulls in the framework's AuthenticatesUsers trait and lets that handle everything:

app/Http/Controllers/Auth/LoginController.php
class LoginController extends Controller
{
    use AuthenticatesUsers;

    protected $redirectTo = '/admin';

    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }

    protected function authenticated(Request $request, $user)
    {
        DB::table('login_logs')->insert([
            'user_id' => $user->id,
            'ip_address' => $request->ip(),
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }
}

No custom rate limiting here, and there doesn't need to be, maxAttempts() defaults to 5 and decayMinutes() defaults to 1, and neither property is overridden anywhere in this class. This is exactly what you'd write yourself following the framework's own documentation. The trait it pulls in is where the ordering actually happens:

vendor/laravel/ui/auth-backend/AuthenticatesUsers.php
public function login(Request $request)
{
    $this->validateLogin($request);

    if (method_exists($this, 'hasTooManyLoginAttempts') &&
        $this->hasTooManyLoginAttempts($request)) {          // 1. CHECK
        $this->fireLockoutEvent($request);
        return $this->sendLockoutResponse($request);
    }

    if ($this->attemptLogin($request)) {                      // 2. SLOW WORK
        // guard()->attempt() runs Hash::check(), bcrypt, 12 rounds
        return $this->sendLoginResponse($request);
    }

    $this->incrementLoginAttempts($request);                  // 3. WRITE
    return $this->sendFailedLoginResponse($request);
}

Step 1 reads the failure count. Step 2 is the entire bcrypt comparison, the slow part, sitting in the middle doing nothing to protect step 3. On the failure path, step 3 is the only place the count gets written (a successful login clears it in sendLoginResponse()). Every concurrent request that reaches step 1 before any other request has reached step 3 sees the same stale count, and passes. ThrottlesLogins itself just wraps a cache increment around this:

vendor/laravel/ui/auth-backend/ThrottlesLogins.php
protected function hasTooManyLoginAttempts(Request $request)
{
    return $this->limiter()->tooManyAttempts(
        $this->throttleKey($request), $this->maxAttempts()
    );
}

protected function incrementLoginAttempts(Request $request)
{
    $this->limiter()->hit(
        $this->throttleKey($request), $this->decayMinutes() * 60
    );
}

It's worth being precise about what is and isn't broken here. Laravel's cache increment itself is atomic, the database cache driver this lab runs on wraps it in a transaction with lockForUpdate(), and Redis's INCR is atomic at the store level too. Nobody's counter gets corrupted, and two requests incrementing at once will never produce the wrong final number. That was never the problem. The problem is that the increment happens too late to matter, after the slow work, not before it. An atomic increment protects the increment. It does nothing for the gap in front of it.

No safety net at the route level either Laravel ships a separate throttle: middleware for exactly this kind of thing, and its version of the same logic increments before calling the controller, shrinking the window to the middleware's own check-and-hit rather than the bcrypt-sized gap. Auth::routes() from laravel/ui doesn't attach it to /login by default, and nobody added it here. Checked directly against this app: php artisan route:list --json shows the POST /login route carrying only web and RedirectIfAuthenticated middleware. ThrottlesLogins is standing there completely alone.

Why This Ships in Real Apps, Not Just Labs

This isn't a bug DVLA's developers introduced. It's the ordering in the unmodified AuthenticatesUsers trait that laravel/ui generates for every project that runs php artisan ui bootstrap --auth. The lab's controller adds its own authenticated() logging hook, but the check/work/write sequence comes straight from the vendor trait. Thousands of real Laravel apps ship this exact ordering, untouched, because it reads as correct and the framework documentation doesn't mention that the check and the increment need to straddle the slow operation in a specific order. "Add rate limiting to login" checklists say to do it. They don't say which side of the password check to do it on.

It's also not obscure research. This exact class, using concurrency to defeat a check-then-increment attempt counter, is one of the canonical labs in PortSwigger's Web Security Academy race condition material, usually demonstrated against a login form or a 2FA code. The pattern has been public and well documented for years. What keeps it alive is that a sequential test, the kind every QA suite runs, one request after another with time to breathe between them, will never trigger it. You only see it by sending requests on top of each other, which is not how anyone tests a login form by hand.

Why This Race Window Is Exploitable in Practice

Three things stack to make this exploitable in practice rather than just true in theory:

  • Bcrypt is slow on purpose. BCRYPT_ROUNDS=12 in this lab's .env, the framework default. That's a good setting, it's what makes offline hash cracking expensive. It's also exactly what turns the race window from "theoretically exists" into "hundreds of milliseconds wide and completely practical to hit."
  • Nothing throttles ahead of the controller. Covered above, no route-level throttle: middleware means every concurrent request reaches AuthenticatesUsers::login() and starts its own bcrypt comparison in parallel, with nothing turning any of them away earlier.
  • The server can actually run them in parallel. This container ships the stock php:8.3.0-fpm pool config, pm.max_children = 5, the base image's default, nobody tuned it. Five workers means five truly concurrent PHP requests at once in this lab, which already leaks a couple of extra attempts per burst, as shown below. Production deployments routinely run pools of dozens to hundreds of workers, or scale horizontally across pods, which doesn't shrink this bug, it widens it.

Proof of Concept

The technique is simple: fetch the login page once to get a session and a CSRF token, then reuse that exact same cookie for a burst of concurrent POST /login requests instead of one at a time.

Doing It in Burp Suite (Community Edition)

Burp Repeater's Send group in parallel option has been in Community Edition since 2023.9.1. On this lab's HTTP/1.1 listener Burp uses last-byte synchronization; against an HTTP/2 target it switches to the single-packet attack.

The one rule: same session, different passwords Each request in the burst must keep the same Cookie header and the same _token. Change only the password field. A stale CSRF token is rejected before the request reaches the login handler, which makes the race look like it failed when it never actually ran.
  1. Open the lab through Burp.

    In Burp, go to Proxy → Intercept and click Open browser. Browse to http://localhost:8084/login. If you use an external browser, proxy it through 127.0.0.1:8080.

    What you should see: the ArtisanBreach login form with the seeded credentials printed on the page. If /login sends you to /admin, you are already logged in — log out or use a private window first.

  2. Capture the browser's login request.

    Turn Intercept is on. In the browser, enter admin@artisanbreach.com and any wrong password, then submit the form. Burp pauses the request before it reaches the server. This is the request, not the response. Right-click it and choose Send to Repeater. You can drop the intercepted copy; the Repeater tab is what you need.

    ArtisanBreach login page with default credentials

    What you should see in Repeater: a POST /login request with a Cookie header and a form body containing _token, email, and password. It should look like this (your token and cookie will differ):

    Burp Repeater showing the captured POST /login request

    The captured request may still show Connection: keep-alive; that is the browser's header. The setting that matters for parallel send is Burp's project-level HTTP/1 keep-alive option.

  3. Send it once to prove the session is valid.

    In Repeater, click Send.

    What you should see: 302 Found with Location: .../login. That is a normal failed login, the session cookie and CSRF token were accepted. If you get 419 Page Expired instead, the token is stale: go back to Step 2 and capture a fresh request.

    Burp Repeater showing 302 Found with Location /login
  4. Reset the failed-attempt counter before the real run.

    The app allows five failed logins per minute. The intercepted submit in Step 2 (if you forwarded it instead of dropping it) and the test send in Step 3 can each use one attempt, so the counter may already be at one or two. To make the burst start from zero, clear the rate limiter from a terminal:

    docker exec dvla-admin php artisan cache:clear

    What you should see: INFO Application cache cleared successfully.

    This is a lab-only reset, not part of the attack. The lab stores sessions in the cookie (SESSION_DRIVER=cookie), so clearing the cache does not invalidate your session or CSRF token. If you do not have shell access, wait 60 seconds for the lockout window to expire instead.

  5. Put the request in a Repeater tab group.

    Click the + tab button and choose Create tab group. Name it login-race, tick the request tab, choose a color, and click Create. Duplicating a tab and sending a group both require a group, so this comes before making copies.

    Burp Repeater create tab group dialog named login-race

    What you should see: a colored group icon on the tab bar with the request tab inside it.

  6. Duplicate the request into twelve tabs.

    Right-click the grouped tab and choose Duplicate tab until you have twelve tabs. Burp keeps every copy in the same group. Double-click each tab header to rename them 01 through 12 so your screenshots line up; these numbers identify the tabs, not the response order.

    What you should see: twelve numbered pills along the top of Repeater, all inside the login-race group.

    Burp Repeater with twelve tabs in the login-race group
  7. Make eleven guesses wrong and one right.

    In tabs 01–06 and 08–12, change the body to password=wrong. In tab 07, change it to password=artisanpass123. Leave _token, email, and Cookie exactly as they are in every tab.

    What you should see: tab 07 is the only one with the real password. Burp recalculates Content-Length on send; if you disabled that, update it manually after changing the longer password.

    Position matters: tab 07 is a middle-of-the-batch example. If it keeps returning /login, move the real password to an earlier tab such as 03 or 05. The server has only five PHP-FPM workers, so the first few requests are the ones most likely to pass the counter check before it reaches five; a tab near the end is usually too late.

    Do not send yet — the next step fires the whole group.

  8. Send the group in parallel.

    Select any tab in the group. Click the drop-down arrow next to Send and choose Send group in parallel, then click Send group (parallel).

    What you should see: the button says Send group (parallel), and all twelve tabs receive responses at once. If the button says (sequence), you picked the wrong option and the requests will not race. Reset the counter (Step 4) and try again.

    Burp Repeater Send group (parallel) with tab 7 returning 302 to /admin
  9. Find the winning response.

    Open tab 07, the one with the real password. If its response is 302 with Location: .../admin, the race landed. If it is 302 with Location: .../login, the lockout blocked it and you need to retry. The other eleven tabs will show /login either way.

    The Location header is the reliable signal. Some Burp builds also show a small n/12 arrival-order badge in the bottom-right of the response panel, next to the size and time; if yours does, capture it, but do not rely on it. To see the dashboard, copy the new artisanbreach_session value from the winning response's Set-Cookie header into your browser and open /admin.

  10. Retry until it lands.

    The correct password is necessary but not sufficient. Laravel checks the lockout counter before it checks the password, so if tab 07 is scheduled after the other tabs have already pushed the counter to five, it is rejected without Hash::check() ever running. In that run tab 07 returns 302 → /login even though the password is right. With five PHP-FPM workers, whether tab 07 is in the first wave depends on scheduling, which is why the result changes between runs.

    “Retry” means: if tab 07 returned /login, reset the counter (Step 4) and send the same group again. If it already returned /admin, you are done. If it keeps failing, move the real password to an earlier tab (03 or 05) and resend. On this lab, with the real password in the middle, it gets through in roughly one run in three, though re-tests have ranged from three to six successes in ten runs. With the real password near the end (for example tab 11), expect to retry far more often.

If something does not work
  • 419 Page Expired — the CSRF token is stale. Recapture the login request and start over.
  • 400 Bad Request on one tab — that request is malformed and never reaches the login handler. Check that it is still POST /login and that its body matches the other tabs. The quickest fix is to delete it, duplicate a working wrong-password tab, and set its password to wrong.
  • Greyed-out "Send group in parallel" — the tabs are not in the same group, they do not all target the same host and port, or Settings → Network → HTTP → HTTP/1 → Use keep-alive for HTTP/1 if the server supports it is enabled.
  • No arrival-order badge — the numbered pills are just tab labels. The badge, when your build has it, is in the bottom-right of the response panel. It is optional; use the Location header.

The three result blocks below show the expected results for each variant. For the all-wrong 20-request burst, duplicate the group to twenty tabs, set every password to wrong, and send them in parallel; the counter finishes at 5 to 9 instead of the expected 5. For the baseline, duplicate the group to six tabs, set the first five to wrong and the sixth to artisanpass123, and use Send group in sequence (separate connections); the sixth request is locked out, which is the control working as intended.

Baseline Prove the lockout works normally when requests aren't raced. Five sequential wrong guesses against admin@artisanbreach.com, one after another, then a sixth sequential request using the real password, artisanpass123 (yes, it's printed right on the login page in this lab):
POST /login  password=wrong-1        -> redirected back to /login
POST /login  password=wrong-2        -> redirected back to /login
POST /login  password=wrong-3        -> redirected back to /login
POST /login  password=wrong-4        -> redirected back to /login
POST /login  password=wrong-5        -> redirected back to /login
POST /login  password=artisanpass123 -> STILL redirected back to /login

Confirmed server-side: RateLimiter::attempts() for that email+IP key sits at exactly 5, and tooManyAttempts(5) reports true. The correct password gets rejected without Laravel even bothering to check it. This is the control doing its job. Sequential requests never trigger the race, there's no concurrent reader to race against.

20-request burst Now the same five wrong guesses, plus fifteen more, all twenty fired as one concurrent burst instead of one at a time:
20x POST /login, fired concurrently, all wrong passwords, same session/CSRF token

Checked the same way, RateLimiter::attempts() for that key after the burst: 5 to 9 in our runs, depending on the run, against a nominal cap of 5. Every value above 5 is an extra password check the lockout should have blocked outright. On a five-worker pool that's a small leak per burst, but it is still a leak, and it is the one the lockout is specifically supposed to make impossible.

12-request mixed burst Repeat the burst, this time with the real password mixed in among the noise, roughly in the middle of a twelve-request batch:
12x POST /login, fired concurrently:
  wrong, wrong, wrong, wrong, wrong, wrong,
  artisanpass123,
  wrong, wrong, wrong, wrong, wrong

Across ten runs of this exact burst against the lab, the real password landed and logged straight into /admin in three of them, roughly one run in three. The other seven got caught by the same lockout as the sequential test, scheduling on a five-worker pool isn't perfectly simultaneous, so not every burst lines up requests tightly enough to matter. That's normal for this bug class, race conditions are probabilistic, not guaranteed, which is exactly why real tooling like Turbo Intruder exists: it fires requests as a single TCP packet with the last byte held back and released simultaneously, tightening the timing far more than a script on the same box managed here. A patient attacker just retries the burst.

Impact of Bypassing the Throttle

Turns a hard cap into a soft one

A 5/min lockout makes a 10,000-word dictionary attack take over 33 hours sequentially. Raced on this five-worker pool the counter still reaches 5-9 checks per burst, and bigger pools widen it. A targeted burst can also put the real password in the first wave instead of waiting its turn.

No prior foothold needed

Works against the public /login form on day one. No leaked key, no cookie, no earlier post in this series required to reach it.

Same bug, any Laravel app

This is the unmodified laravel/ui AuthenticatesUsers trait. Wherever it ships behind a slow hash check with nothing extra added, the same race exists.

Looks normal in the logs

Failed attempts never reach login_logs; that table only records successes. In nginx's access log they are ordinary 302s, so nothing stands out unless you graph request rate per IP.

Cheap to run

No gadget chain, no cracked key, no research. A handful of concurrent HTTP requests from a stock scripting language is the entire exploit.

Remediation

The fixes that matter ArtisanBreach stays on the unmodified scaffolding, that's the point of the lab. Below is what closes this in a real application.
Where the code lives The vulnerable ordering is in vendor/laravel/ui/auth-backend/AuthenticatesUsers.php, and the check/increment helpers are in vendor/laravel/ui/auth-backend/ThrottlesLogins.php. Both are pulled into app/Http/Controllers/Auth/LoginController.php by the AuthenticatesUsers trait. Do not edit the vendor files — Composer overwrites them. Put the overrides in the app controller, and put the route change in routes/web.php.
Fix 1 Reserve the attempt before the slow work, not after it.
app/Http/Controllers/Auth/LoginController.php

Where this goes: add this login() method to the LoginController class, below the existing authenticated() method. A method defined on the class overrides the one from the AuthenticatesUsers trait, so this is the app-owned place to fix the ordering without editing vendor/.

Flip the order so the counter is incremented as soon as the request is accepted for processing, before attemptLogin() ever touches bcrypt, and only cleared on success:

public function login(Request $request)
{
    $this->validateLogin($request);

    $key = $this->throttleKey($request);

    if ($this->limiter()->tooManyAttempts($key, $this->maxAttempts())) {
        $this->fireLockoutEvent($request);
        return $this->sendLockoutResponse($request);
    }

    // Reserve the slot first. This is now the only step that has to be
    // atomic, and cache increments already are.
    $this->limiter()->hit($key, $this->decayMinutes() * 60);

    if ($this->attemptLogin($request)) {
        $this->limiter()->resetAttempts($key);
        return $this->sendLoginResponse($request);
    }

    return $this->sendFailedLoginResponse($request);
}

The race window shrinks from "however long bcrypt takes" to "however long an atomic cache increment takes," a few milliseconds instead of a few hundred. That's not a theoretical improvement, it's closing the actual gap the exploit above lives in.

Fix 2 Add route-level throttling in front of the controller as well.
routes/web.php

Where this goes: at the top of routes/web.php, where the bare Auth::routes(); line currently sits. The login routes are defined explicitly so the POST route can carry the throttle; Auth::routes(['login' => false]) then registers the rest of the auth routes without creating a duplicate /login. Keep any other options your app already passes to Auth::routes(); this example assumes the lab's bare call.

use App\Http\Controllers\Auth\LoginController;

Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');

Route::post('login', [LoginController::class, 'login'])
    ->middleware('throttle:10,1');

Auth::routes(['login' => false]);

The use statement goes at the top of the file with the other imports. This routes file currently uses fully qualified class names, so you can also write [\App\Http\Controllers\Auth\LoginController::class, 'login'] instead.

Laravel's built-in ThrottleRequests middleware increments its counter before calling the next handler in the chain, not after, so the throttle check happens before the controller ever reaches bcrypt. It rate-limits by IP regardless of which account is being guessed. It is not a perfectly atomic check, but it shrinks the gap to the middleware's own check-and-hit instead of the bcrypt-sized window.

This doesn't replace the per-account lockout, a shared IP behind NAT shouldn't get one visitor's mistakes counted against everyone else, but it caps how many login attempts can even reach the controller per IP per minute, which shrinks how many concurrent bcrypt comparisons can ever be in flight at once.

Fix 3 Wrap check-then-act sequences that can't be reordered in an explicit lock.
app/Http/Controllers/Auth/LoginController.php

Where this goes: this is the same login() method as Fix 1, but with the whole check-and-attempt sequence inside Cache::lock(). Add use Illuminate\Support\Facades\Cache; at the top of the controller. The lock key is the same per-account-per-IP throttle key, so only one request for that key runs the check at a time.

Not every race condition can be fixed by moving a line; sometimes the "slow work" has to happen before you know whether to write. For those cases, an atomic lock forces concurrent callers to queue instead of racing:

public function login(Request $request)
{
    $this->validateLogin($request);

    $key = $this->throttleKey($request);

    return Cache::lock("login-lock:{$key}", 5)->block(10, function () use ($request, $key) {
        if ($this->limiter()->tooManyAttempts($key, $this->maxAttempts())) {
            return $this->sendLockoutResponse($request);
        }

        if ($this->attemptLogin($request)) {
            $this->limiter()->resetAttempts($key);
            return $this->sendLoginResponse($request);
        }

        $this->limiter()->hit($key, $this->decayMinutes() * 60);
        return $this->sendFailedLoginResponse($request);
    });
}

The 5 is how long the lock lives if the process dies; the block(10) is how long a caller waits to acquire it. Ten seconds is enough for a twelve-request burst at roughly 0.3 seconds of bcrypt per request; increase it if you expect a larger burst.

Cache store requirement: Cache::lock() needs a lock-capable store. The lab's CACHE_STORE=database supports it, as does Redis. If the lock cannot be acquired within ten seconds, Laravel throws a LockTimeoutException; catch it and return a 429 or 503 instead of a stack trace.

This is heavier than Fix 1, every request for the same key now waits its turn instead of running in parallel, so use it where reordering genuinely isn't possible rather than as the default answer to every race condition.

Remember to clear the routes after applying these changes php artisan route:clear

Remediation Checklist

Grep for any handler shaped like "check a counter, do something slow, increment the counter", in that order. That ordering is the bug, in any language, in any framework.
Move the increment, or the lock acquisition, to before the slow operation rather than after it, so the reservation happens while it's still cheap and fast to do.
Add route-level throttle: middleware to authentication endpoints in addition to any per-account lockout. It increments before dispatching to the controller and closes the gap from the edge.
Test rate limits with concurrent requests, not a loop with a delay between them. A sequential test will never catch this bug, only a real burst will.
For check-then-act sequences that genuinely can't be reordered, wrap them in Cache::lock() so concurrent callers queue instead of racing.
Don't assume default framework scaffolding is safe by default. This exact ordering ships unmodified in laravel/ui projects generated with --auth, and it's been documented publicly for years.