# Rate limiting with Exponential Backoff in Laravel

By Shivaji Chalise
Published: 2025-05-20
Updated: 2025-05-21
Canonical: https://laranepal.com/blog/rate-limiting-with-exponential-backoff-in-laravel

[Brute-force](https://owasp.org/www-community/attacks/Brute_force_attack) 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
<?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 // [tl! collapse:start]
    {
        // 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;
    } // [tl! collapse:end]

    /**
     * Increment the number of failed attempts.
     * If it's the first attempt, create the cache entry.
     */
    private function incrementAttempts(): int // [tl! collapse:start]
    {
        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);
    } // [tl! collapse:end]

    /**
     * Check how many seconds remain until the lockout expires.
     */
    private function secondsUntilUnlock(): int //[tl! collapse:start]
    {
        $unlockTimestamp = Cache::get($this->lockKey);

        // If not locked, allow login
        if (! $unlockTimestamp) {
            return 0;
        }

        // Calculate remaining time from now
        return max(1, $unlockTimestamp - time());
    } // [tl! collapse:end]

    /**
     * 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
<?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.

```php
$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.

```php
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.

```php
$user = User::where('email', $fields['email'])->first();
```

Try to find the user by email.

```php
if (! $user || ! Hash::check($fields['password'], $user->password)) {
```

If the user doesn't exist or the password doesn't match, we begin throttling logic.

```php
$attempts = $this->incrementAttempts();
```

This method tracks how many failed login attempts have happened in the last minute.

```php
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`

```php
throw new TooManyRequestsHttpException(
    $delay,
    "Too many attempts. Try again in {$delay} seconds.",
    null,
    429
);
```

Immediately throw a 429 response, stopping the login flow.

```php
$remaining = $this->freeAttempts - $attempts;
```

Calculate how many free attempts are left. For example, if they've tried once, `remaining = 2`.

```php
if ($remaining > 1) {
    throw new UnauthorizedHttpException(
        'Basic',
        "Invalid credentials. {$remaining} attempts left.",
        null,
        401
    );
}
```

Clear and user-friendly warnings guide them gently.

```php
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`

```php
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`

```php
$unlockTimestamp = Cache::get($this->lockKey);
```

If there's a lock in place, it holds a UNIX timestamp (like `time() + 8`).

```php
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`

```php
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

---
