Implementing login attempt limiting (Brute-Force Protection) on a website
Password guessing attacks are one of the most common vectors for account compromise. Without limits, attackers can check thousands of passwords per minute using automated scripts.
Protection Strategies
Brute-force protection is built in multiple layers:
- Rate limiting — limit the number of attempts per period
- Account lockout — temporary account suspension
- IP blocking — block the attack source
- Progressive delays — increasing delays between attempts
- CAPTCHA — after N failed attempts
Laravel Implementation
// app/Http/Controllers/Auth/LoginController.php
class LoginController extends Controller
{
protected int $maxAttempts = 5;
protected int $decayMinutes = 15;
public function login(Request $request)
{
// Laravel's built-in throttle uses caching (Redis/database)
if ($this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
return $this->sendLockoutResponse($request);
}
if (Auth::attempt($request->only('email', 'password'))) {
$this->clearLoginAttempts($request);
return redirect()->intended('/dashboard');
}
$this->incrementLoginAttempts($request);
return back()->withErrors(['email' => 'Invalid credentials']);
}
}
The ThrottlesLogins trait uses a key like email|IP — it blocks a specific combination, not the entire IP.
Custom Implementation via Redis
class BruteForceProtection
{
private Redis $redis;
private int $maxAttempts = 5;
private int $lockoutSeconds = 900; // 15 minutes
private int $windowSeconds = 300; // 5 minutes
public function attempt(string $key): bool
{
$redisKey = "login_attempts:{$key}";
$count = $this->redis->incr($redisKey);
if ($count === 1) {
$this->redis->expire($redisKey, $this->windowSeconds);
}
if ($count > $this->maxAttempts) {
$this->redis->setex("lockout:{$key}", $this->lockoutSeconds, 1);
return false;
}
return true;
}
public function isLocked(string $key): bool
{
return (bool) $this->redis->exists("lockout:{$key}");
}
public function getLockoutTtl(string $key): int
{
return $this->redis->ttl("lockout:{$key}");
}
public function reset(string $key): void
{
$this->redis->del("login_attempts:{$key}", "lockout:{$key}");
}
}
Progressive Delay (increasing delays)
Instead of complete blocking — increase delay with each failed attempt:
// Delays: 1s → 2s → 4s → 8s → 16s → 32s (maximum)
$delay = min(pow(2, $attempts - 1), 32);
sleep($delay);
This slows down automated guessing without completely disabling the login function.
Blocking Differentiation
It's important to separate blocking levels:
| Level | Key | Condition | Duration |
|---|---|---|---|
| By email | login:email:[email protected] |
5 attempts in 5 min | 15 min |
| By IP | login:ip:1.2.3.4 |
20 attempts in 5 min | 30 min |
| Global | login:global |
1000 attempts in 1 min | Alert |
Blocking only by IP can harm users behind NAT/proxy. Blocking only by email is easy to bypass from different IPs.
CAPTCHA after Failed Attempts
// In controller
$attempts = $this->limiter->attempts($throttleKey);
$showCaptcha = $attempts >= 3;
return view('auth.login', compact('showCaptcha'));
@if ($showCaptcha)
<div class="cf-turnstile" data-sitekey="{{ config('services.turnstile.site_key') }}"></div>
@endif
User Notification
Upon successful login after multiple failed attempts — email notification:
// After successful authentication
if ($previousFailedAttempts > 2) {
Mail::to($user)->queue(new SuspiciousLoginNotification($request->ip()));
}
Monitoring
Log all failed attempts in structured format:
Log::warning('Failed login attempt', [
'email' => $request->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'timestamp' => now()->toIso8601String(),
]);
Alert in Grafana/Datadog on spike in failed login events — sign of active attack.
Implementation Timeline
- Basic Laravel throttle: 1 day
- Custom Redis implementation with progressive delays: 2–3 days
- Integration with monitoring and notifications: +1 day







