Brute-force login attacks are common and dangerous. Handling such attacks during login is critical for security.
Laravel offers basic rate-limiting tools, but sometimes you need more flexibility.
This tutorial walks you step by step through implementing a custom login rate limiter with exponential delay in Laravel.
We will implement:
- 3 free login attempts per email + IP combo
- Exponential lockout delays (2s, 4s, 8s, ... up to 60s max)
- Reset lockout and attempt count on successful login
- Fully customizable logic using Laravel's
Cache
and exception handling
Let's dive in.
Folder Setup
We'll use an Action class to keep the logic clean.
app/Actions/AttemptLoginAction.php
handles login with the rate limiting logicApp\Http\Controllers\AuthController::login
calls the action
Step 1: Create the Action Class
Paste the complete implementation below. We'll break down how it works-line by line-later in the article.
app/Actions/AttemptLoginAction.php
<?php declare(strict_types=1); namespace App\Actions; use App\Models\User;use Illuminate\Support\Facades\Cache;use Illuminate\Support\Facades\Hash;use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; final class AttemptLoginAction{ /** * Number of free login attempts before rate limiting kicks in. */ private int $freeAttempts = 3; /** * Cache key used to track failed attempts per user+IP. */ private string $attemptKey; /** * Cache key used to store lockout expiration per user+IP. */ private string $lockKey; /** * Handle the login logic: verify credentials, track attempts, * and apply lockout with exponential delay if needed. * * @throws TooManyRequestsHttpException If the user is locked out * @throws UnauthorizedHttpException If the credentials are invalid */ public function handle(array $fields, string $ip): User
{ // Generate unique cache keys based on user email and IP $this->attemptKey = "login:attempts:{$fields['email']}-{$ip}"; $this->lockKey = "login:lock:{$fields['email']}-{$ip}"; // If the user is currently locked out, throw an exception with wait time if ($seconds = $this->secondsUntilUnlock()) { throw new TooManyRequestsHttpException( $seconds, "Too many attempts. Try again in {$seconds} seconds.", null, 429 ); } $user = User::where('email', $fields['email'])->first(); if (! $user || ! Hash::check($fields['password'], $user->password)) { // Increase the failed attempt count $attempts = $this->incrementAttempts(); // If attempts to exceed the free limit, apply exponential backoff if ($attempts > $this->freeAttempts) { $delay = min(60, pow(2, $attempts - $this->freeAttempts)); // exponential: 2, 4, 8, ... $this->lockFor($delay); throw new TooManyRequestsHttpException( $delay, "Too many attempts. Try again in {$delay} seconds.", null, 429 ); } // Inform the user how many tries are left $remaining = $this->freeAttempts - $attempts; if ($remaining > 1) { throw new UnauthorizedHttpException('Basic', "Invalid credentials. {$remaining} attempts left.", null, 401); } if ($remaining === 1) { throw new UnauthorizedHttpException('Basic', 'Invalid credentials. Last attempt remaining.', null, 401); } throw new UnauthorizedHttpException( 'Basic', 'Invalid credentials. Account will be locked on next failure.', null, 401, ); } // Login successful – clear previous attempt and lockout data Cache::forget($this->attemptKey); Cache::forget($this->lockKey); return $user; } /** * Increment the number of failed attempts. * If it's the first attempt, create the cache entry. */ private function incrementAttempts(): int
{ if (! Cache::has($this->attemptKey)) { // First failure – set initial count to 1 for 1 minute Cache::put($this->attemptKey, 1, now()->addMinute()); return 1; } // Otherwise, just increment the count return Cache::increment($this->attemptKey); } /** * Check how many seconds remain until the lockout expires. */ private function secondsUntilUnlock(): int
{ $unlockTimestamp = Cache::get($this->lockKey); // If not locked, allow login if (! $unlockTimestamp) { return 0; } // Calculate remaining time from now return max(1, $unlockTimestamp - time()); } /** * Lock the user out for a specific duration. */ private function lockFor(int $seconds): void { // Store the unlock time with expiration Cache::put($this->lockKey, time() + $seconds, $seconds); }}
Step 2: Update Your Login Controller
<?php use App\Actions\AttemptLoginAction;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;use Throwable; /** * Handle the login request with validation and rate limiting. */public function login(Request $request, AttemptLoginAction $action): JsonResponse{ $fields = $request->validate([ 'email' => ['required', 'email:rfc'], 'password' => ['required', 'string'], ]); try { $user = $action->handle($fields, $request->ip()); return response()->json([ 'status' => true, 'message' => 'Login successful', 'user' => [ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, ], ]); } catch (Throwable $th) { $statusCode = $th instanceof HttpExceptionInterface ? $th->getStatusCode() : 500; $message = config('app.env') === 'production' ? 'Something went wrong. Please try again later.' : $th->getMessage(); return response()->json([ 'status' => false, 'message' => $message, ], $statusCode); }}
Code Breakdown & Explanation
handle(array $fields, string $ip): User
This is the core method that gets called by your controller.
$this->attemptKey = "login:attempts:{$fields['email']}-{$ip}";$this->lockKey = "login:lock:{$fields['email']}-{$ip}";
We create two cache keys:
attemptKey
: stores how many times this email+IP has failed login.lockKey
: if a user is rate limited, this stores how long until they can try again.
if ($seconds = $this->secondsUntilUnlock()) { throw new TooManyRequestsHttpException( $seconds, "Too many attempts. Try again in {$seconds} seconds.", null, 429 );}
If the user is currently locked out, we throw an exception with the remaining wait time.
$user = User::where('email', $fields['email'])->first();
Try to find the user by email.
if (! $user || ! Hash::check($fields['password'], $user->password)) {
If the user doesn't exist or the password doesn't match, we begin throttling logic.
$attempts = $this->incrementAttempts();
This method tracks how many failed login attempts have happened in the last minute.
if ($attempts > $this->freeAttempts) { $delay = min(60, pow(2, $attempts - $this->freeAttempts)); $this->lockFor($delay);
Once the user exceeds the allowed attempts (3
), we begin exponential lockouts:
- 1st lock:
2s
- 2nd lock:
4s
- 3rd lock:
8s
- ... and so on, maxing out at
60s
throw new TooManyRequestsHttpException( $delay, "Too many attempts. Try again in {$delay} seconds.", null, 429);
Immediately throw a 429 response, stopping the login flow.
$remaining = $this->freeAttempts - $attempts;
Calculate how many free attempts are left. For example, if they've tried once, remaining = 2
.
if ($remaining > 1) { throw new UnauthorizedHttpException( 'Basic', "Invalid credentials. {$remaining} attempts left.", null, 401 );}
Clear and user-friendly warnings guide them gently.
Cache::forget($this->attemptKey);Cache::forget($this->lockKey);
If login is successful, we clear the login attempts and lockout, giving the user a clean slate.
incrementAttempts(): int
if (! Cache::has($this->attemptKey)) { Cache::put($this->attemptKey, 1, now()->addMinute()); return 1;} return Cache::increment($this->attemptKey);
- On the first failure, we create a new counter set to
1
that expires in1 minute
. - On further failures, we just
increment()
the count.
This logic auto-resets after 1 minute, so accidental typos don't haunt the user forever.
secondsUntilUnlock(): int
$unlockTimestamp = Cache::get($this->lockKey);
If there's a lock in place, it holds a UNIX timestamp (like time() + 8
).
return max(1, $unlockTimestamp - time());
We subtract the current time from unlock time to get the seconds left.
If it's already expired, return 0
.
lockFor(int $seconds): void
Cache::put($this->lockKey, time() + $seconds, $seconds);
We set:
- The unlock time to now + delay (e.g. 20 seconds from now)
- The cache TTL so it auto-expires after
$seconds
This means the lockout will auto-remove itself without manual intervention.
Real World Example
Let's say a user enters the wrong password 6 times in a row:
Attempt | Lockout? | Wait Time |
---|---|---|
1st | No | - |
2nd | No | - |
3rd | No | - |
4th | Yes | 2 seconds |
5th | Yes | 4 seconds |
6th | Yes | 8 seconds |
Once they enter the correct password, everything resets.
Conclusion
Implementing a custom login throttling system with exponential backoff provides better control over authentication security in Laravel - all without third-party packages.
This gives you:
- More flexibility than Laravel’s built-in rate limiting
- Clearer user feedback
- Easy customization
- Better brute-force protection
For production, make sure you're using a high-performance cache driver like Redis.
You can also take this further by:
- Logging attempts for monitoring
- Adding a config file to control timing behavior
- Alerting admins of repeated failed attempts

Detail-oriented Backend Engineer dedicated to building high-quality applications.