Why Default IP Throttling Fails Against Credential Stuffing
Standard rate limiters attach a counter to the client's IP address. If a single IP makes six bad attempts in 60 seconds, Laravel blocks it. That works fine against crude script kiddies, but credential stuffing attacks don't work like that. Attackers buy lists of leaked email and password pairs from breaches, then feed them into distributed botnets with thousands of clean residential IP addresses.
When each IP only attempts one login every ten minutes, your standard 60-requests-per-minute IP throttle never triggers. Yet, your database gets hit with 100,000 authentication checks an hour, and targeted user accounts eventually get compromised. To block this, you need compound throttle keys and progressive lockout rules.
Designing Dual-Key Rate Limiting in Laravel 12
Laravel 12 configures rate limiting inside app/Providers/AppServiceProvider.php or directly within custom middleware using the RateLimiter facade. In PHP 8.3, typed constants and concise match expressions let us write clean limiters that check both the IP address and the targeted account identifier.
We want two separate counters for every login attempt:
- Per-IP Throttle: Limits total login attempts from a single IP address (for example, 10 attempts per minute). This stops single-source brute forcing.
- Per-Account Throttle: Limits total login attempts against a specific email or username across all IPs (for example, 5 attempts per 5 minutes). This stops distributed attacks targeting a single account.
Here is how you set up a custom rate limiter definition using RateLimiter::for inside your service provider:
namespace App\Providers;
use Illuminate\Cache\RateLimiter\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
class AppServiceProvider extends ServiceProvider
{
public void boot(): void
{
RateLimiter::for('login', function (Request $request) {
$email = (string) $request->input('email');
$throttleKey = Str::transliterate(Str::lower($email) . '|' . $request->ip());
return [
Limit::perMinute(10)->by($throttleKey),
Limit::perMinutes(5, 5)->by(Str::lower($email)),
];
});
}
}Returning an array of Limit objects forces Laravel to evaluate all of them. If any single limit is exceeded, the overall check fails and returns HTTP 429.
Building an Action Class with Exponential Backoff
The standard ThrottleRequests middleware handles basic API routes, but login flows usually require custom response payloads, structured audit logs, and dynamic lockout timers. Moving this logic into a dedicated action class gives you complete control over authentication failures.
When an attacker hits an account repeatedly, a fixed 60-second window isn't enough. We want exponential decay times: 1 minute for the first ban, 15 minutes for the second, and 1 hour for subsequent failures. We track the penalty level in Redis using Laravel's Cache facade.
namespace App\Actions\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class AuthenticateUserAction
{
private const MAX_ATTEMPTS = 5;
public function execute(string $email, string $password, string $ip): bool
{
$key = 'login_attempts:' . mb_strtolower($email);
if (RateLimiter::tooManyAttempts($key, self::MAX_ATTEMPTS)) {
$seconds = RateLimiter::availableIn($key);
Log::warning('Credential stuffing attempt blocked', [
'email' => $email,
'ip' => $ip,
'retry_after_seconds' => $seconds,
'event_type' => 'auth.throttle.triggered',
]);
throw ValidationException::withMessages([
'email' => ["Too many failed login attempts. Please try again in {$seconds} seconds."],
])->status(429);
}
if (! auth()->attempt(['email' => $email, 'password' => $password])) {
$decay = $this->calculateDecaySeconds($email);
RateLimiter::hit($key, $decay);
Log::info('Failed login attempt', [
'email' => $email,
'ip' => $ip,
'attempts_left' => RateLimiter::remaining($key, self::MAX_ATTEMPTS),
]);
throw ValidationException::withMessages([
'email' => ['These credentials do not match our records.'],
]);
}
RateLimiter::clear($key);
Cache::forget('lockout_tier:' . mb_strtolower($email));
return true;
}
private function calculateDecaySeconds(string $email): int
{
$tierKey = 'lockout_tier:' . mb_strtolower($email);
$tier = Cache::get($tierKey, 1);
$decayMinutes = match ($tier) {
1 => 1,
2 => 15,
default => 60,
};
Cache::put($tierKey, min($tier + 1, 3), now()->addHours(24));
return $decayMinutes * 60;
}
}Consuming Rate Limit Headers in Next.js 16 and React 19
When Laravel responds with HTTP 429, it sends Retry-After and X-RateLimit-Reset headers. Your frontend needs to inspect these headers and present useful UI feedback instead of generic error toasts.
In Next.js 16 App Router using React 19 client components, you can extract the Retry-After header from the fetch response and manage a countdown timer state to inform the user exactly when they can try again.
'use client';
import { useState, useEffect } from 'react';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [retrySeconds, setRetrySeconds] = useState<number | null>(null);
useEffect(() => {
if (retrySeconds === null || retrySeconds <= 0) return;
const timer = setInterval(() => {
setRetrySeconds((prev) => (prev && prev > 1 ? prev - 1 : null));
}, 1000);
return () => clearInterval(timer);
}, [retrySeconds]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (res.status === 429) {
const retryHeader = res.headers.get('Retry-After');
const seconds = retryHeader ? parseInt(retryHeader, 10) : 60;
setRetrySeconds(seconds);
setError('Account temporarily locked due to repeated failed attempts.');
return;
}
if (!res.ok) {
const data = await res.json();
setError(data.errors?.email?.[0] || 'Authentication failed.');
return;
}
window.location.href = '/dashboard';
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={retrySeconds !== null}
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={retrySeconds !== null}
required
/>
<button type="submit" disabled={retrySeconds !== null}>
{retrySeconds ? `Try again in ${retrySeconds}s` : 'Log In'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}Notice that we disable the form inputs while retrySeconds is active. This prevents impatient users from hammering the submit button and generating unnecessary server load while locked out.
Gotchas and Production Settings
Rate limiting looks simple until you push to production behind a reverse proxy or load balancer like Cloudflare or AWS ALB. Here are three things that break if you don't configure them properly:
- Missing Trusted Proxies: If Laravel doesn't trust your reverse proxy,
$request->ip()returns the proxy's IP address instead of the end user's. A single attack will lock out every user on your application because everyone shares the same IP address in Laravel's eyes. In Laravel 12, configure trusted proxies inbootstrap/app.phpusing$middleware->trustProxies(...). - Redis Cache Driver: The default array or file cache drivers don't share state across multiple app servers or worker processes. Use Redis or Memcached in production. If you use file caching, rate limits are only enforced per-server, making multi-node load balancing useless for protection.
- Time Sync Clock Drift: Redis rate limiting relies on absolute Unix timestamps. If your app servers suffer clock drift relative to Redis, users will experience premature locks or indefinite retry loops. Ensure system clocks are synchronized via NTP.
Structured Security Logging and Alerting
Rate limiting stops the breach, but structured logs give you visibility into whether you're being targeted. When logging authentication failures, don't just write unformatted strings to a file. Include standard fields that your log aggregator (Datadog, AWS CloudWatch, or Grafana Loki) can parse and index.
Log entries should include the normalized user identity, client IP, user agent, and failure reason. Here is an example log structure you should standardize across your authentication services:
Log::notice('Authentication failure threshold exceeded', [
'event' => 'auth.rate_limit_exceeded',
'target_account' => Str::lower($email),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'attempts_in_window' => RateLimiter::attempts($key),
'lockout_duration' => $seconds,
]);Set up an alert rule in your monitoring service when event === 'auth.rate_limit_exceeded' fires more than 50 times in 5 minutes across your environment. That metric signals an active credential stuffing campaign and gives your operations team early warning before users start filing support tickets.












