Create AWESOME code snippets and share them with the world in seconds!

X

Rate Limiting With Exponential Backoff In Laravel

Published on May 21, 2025 by

Rate limiting with Exponential Backoff in Laravel

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 logic
  • App\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 in 1 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

Shivaji Chalise

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

Filed in:

Discussion

Login or register to comment or ask questions

No comments or questions yet...

SPONSORED
Codesnap

Codesnap

Create AWESOME code snippets and share them with the world in seconds!