Skip to content

Instantly share code, notes, and snippets.

@shivajichalise
Created May 21, 2025 06:07
Show Gist options
  • Select an option

  • Save shivajichalise/cb6c5327f5d7c6f517b8ca54943d5ca4 to your computer and use it in GitHub Desktop.

Select an option

Save shivajichalise/cb6c5327f5d7c6f517b8ca54943d5ca4 to your computer and use it in GitHub Desktop.
Rate limiting with Exponential Backoff in Laravel
<?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 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);
}
}
<?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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment