Rate Limiting in Laravel and PHP - How to Stop Brute Force Before It Starts
ORIGINALLY PUBLISHED ON MEDIUM This is the thirteenth article in a series on PHP and Laravel application security. So far we have covered: - Detecting SQL injection attempts in PHP logs - Why URL encoding blinds most PHP security checks - The decode bomb problem with unlimited URL decoding - Why parameterized queries are the only real fix for SQL injection - XSS prevention in Laravel and why {!! !!} is the line between safe and hacked - How attackers enumerate your Laravel app before exploiting it - File upload security - the file that isn't what it claims to be - Path traversal in PHP - how ../ escapes your application - Command injection in PHP - when exec() becomes an attack surface - Broken access control in Laravel - why being logged in is not enough - Secrets in Laravel - why .env is only the beginning - Session security in PHP - what most developers get wrong Every article in this series follows the same principle: understand the attack before you try to stop it. Brute force attacks are not sophisticated. They do not require exploiting a vulnerability in your code. They require only that your application accepts unlimited login attempts and most PHP applications do. What Brute Force Actually Is A brute force attack is when an attacker tries many passwords against a login form hoping one works. Modern brute force attacks are fully automated. A script sends login requests as fast as your server will accept them. Simple brute force tries every possible password combination. Slow but exhaustive. Dictionary attacks try a list of common passwords password123 , qwerty , admin , letmein . Fast and effective because most users choose predictable passwords. Credential stuffing uses username and password combinations leaked from other breached websites. If a user reused their password from a previously breached service the attacker gets in immediately without guessing anything. This is the most effective modern login attack because it exploits human behavior rather than application weaknesses. All three share one characteristic they require many requests. Rate limiting makes all three significantly harder. Rate Limiting in Plain PHP PHP has no built-in rate limiting. You implement it using a storage mechanism to track attempts. Session-based - for demonstration only: session_start(); function checkRateLimit(int $maxAttempts, int $windowSeconds): bool { $key = 'login_attempts'; $windowKey = 'login_window_start'; $now = time(); if (!isset($_SESSION[$windowKey]) || ($now - $_SESSION[$windowKey]) > $windowSeconds) { $_SESSION[$windowKey] = $now; $_SESSION[$key] = 0; } $_SESSION[$key]++; return $_SESSION[$key] prepare(' SELECT COUNT(*) FROM login_attempts WHERE identifier = ? AND attempted_at > ? '); $stmt->execute([$identifier, $windowStart]); $count = (int) $stmt->fetchColumn(); if ($count >= $maxAttempts) { return false; } $stmt = $pdo->prepare(' INSERT INTO login_attempts (identifier, attempted_at) VALUES (?, ?) '); $stmt->execute([$identifier, $now]); return true; } $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; if (!checkRateLimitDb($pdo, 'login:ip:' . $ip, 5, 60)) { http_response_code(429); header('Retry-After: 60'); die('Too many login attempts. Please try again in 60 seconds.'); } Redis-based - the production standard: function checkRateLimitRedis( Redis $redis, string $identifier, int $maxAttempts, int $windowSeconds ): bool { $key = 'rate_limit:' . $identifier; $attempts = $redis->incr($key); if ($attempts === 1) { $redis->expire($key, $windowSeconds); } return $attempts connect('127.0.0.1', 6379); $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $email = strtolower(trim($_POST['email'] ?? '')); $ipAllowed = checkRateLimitRedis($redis, 'login:ip:' . $ip, 10, 60); $emailAllowed = checkRateLimitRedis($redis, 'loginπ§' . $email, 5, 300); if (!$ipAllowed || !$emailAllowed) { http_response_code(429); header('Retry-After: 60'); die('Too many attempts. Please try again later.'); } Note on atomicity: the INCR followed by EXPIRE sequence is not a single atomic operation. In rare failure scenarios the key could be incremented without its expiry being set. For critical production implementations use a Redis transaction or Lua script to make the operation fully atomic. Rate Limiting by Multiple Factors Rate limiting only by IP has a weakness botnets distribute attempts across thousands of addresses, each making only a few requests. Rate limiting by multiple factors closes this gap: $ipAllowed = checkRateLimitRedis($redis, 'login:ip:' . $ip, 10, 60); $emailAllowed = checkRateLimitRedis($redis, 'loginπ§' . $email, 5, 300); $combinedAllowed = checkRateLimitRedis($redis, 'login:combined:' . $ip . ':' . $email, 3, 60); if (!$ipAllowed || !$emailAllowed || !$combinedAllowed) { http_response_code(429); header('Retry-After: 60'); die('Too many attempts. Please try again later.'); } - By IP - stops automated attacks from single sources - By email - prevents rotating IPs to attack one account - By IP plus email - the most targeted limit for a specific attacker targeting a specific account Progressive Delays Instead of hard blocking after a threshold progressive delays increase the wait time after each failed attempt: $delays = [0, 0, 0, 2, 5, 10, 30, 60]; $attempts = (int) ($redis->get('login:attempts:' . $ip) ?: 0); $delay = $delays[min($attempts, count($delays) - 1)]; if ($delay > 0) { sleep($delay); } Legitimate users who mistype their password experience a small wait but are not hard blocked. Automated attackers are slowed dramatically because every attempt costs them time. Rate Limiting in Laravel Laravel has a built-in rate limiting system that handles most of what you would build manually. The throttle middleware - simplest approach: Route::post('/login', [AuthController::class, 'login']) ->middleware('throttle:5,1'); // 5 attempts per 1 minute per IP This is the minimum. For a production login endpoint you need more control. Named rate limiters - the correct production approach: use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('login', function (Request $request) { return [ Limit::perMinute(5)->by($request->ip()), Limit::perMinutes(5, 10)->by($request->input('email')), ]; }); RateLimiter::for('api', function (Request $request) { return $request->user() ? Limit::perMinute(60)->by($request->user()->id) : Limit::perMinute(10)->by($request->ip()); }); RateLimiter::for('sensitive', function (Request $request) { return [ Limit::perMinute(3)->by($request->ip()), Limit::perHour(10)->by($request->ip()), ]; }); Route::post('/login', [AuthController::class, 'login']) ->middleware('throttle:login'); Route::post('/password/reset', [PasswordController::class, 'send']) ->middleware('throttle:sensitive'); Route::middleware('throttle:api')->group(function () { Route::get('/user', [UserController::class, 'show']); Route::apiResource('invoices', InvoiceController::class); }); Manual rate limiting in controllers - maximum control: use Illuminate\Support\Facades\RateLimiter; public function login(Request $request) { $key = 'login:' . $request->ip() . ':' . $request->input('email'); if (RateLimiter::tooManyAttempts($key, 5)) { $seconds = RateLimiter::availableIn($key); return response()->json([ 'message' => "Too many attempts. Try again in {$seconds} seconds." ], 429); } if (!Auth::attempt($request->only('email', 'password'))) { RateLimiter::hit($key, 60); return response()->json(['message' => 'Invalid credentials.'], 401); } RateLimiter::clear($key); $request->session()->regenerate(); return response()->json(['message' => 'Authenticated.']); } Four methods to understand: - RateLimiter::hit($key, $decay) - records an attempt with a decay time in seconds - RateLimiter::tooManyAttempts($key, $maxAttempts) - checks if the limit is exceeded - RateLimiter::clear($key) - resets the counter after successful login so a legitimate user starts fresh - RateLimiter::availableIn($key) - returns seconds until the limit resets for user-friendly error messages Redis-backed rate limiting for production: # .env CACHE_DRIVER=redis REDIS_HOST=127.0.0.1 REDIS_PORT=6379 With Redis as the cache driver Laravel's rate limiting is atomic, fast, and automatically distributed across multiple application servers. What to Rate Limit Always - strict limits: - Login endpoints - Password reset requests - Registration forms - Email verification resends - OTP and 2FA code submission - Account deletion confirmation Moderate limits: - API endpoints for authenticated users - Search endpoints - File upload endpoints Generous limits: - Public API endpoints - Contact forms - Comment submission Responding to Rate Limit Violations How you respond matters as much as whether you rate limit: // Wrong - reveals too much return response()->json([ 'error' => 'You have made 5 failed login attempts for u***@example.com' ], 429); // Correct - generic with retry timing return response()->json([ 'message' => 'Too many attempts. Please try again later.', 'retry_after' => $seconds ], 429); Never reveal which factor triggered the rate limit. Never reveal how many attempts were made. Never reveal whether the account exists. Generic messages protect against attackers using rate limit responses to enumerate valid accounts. The Rate Limiting Checklist For plain PHP: - Use Redis for rate limiting in production not sessions - Rate limit by IP address and by username separately - Be aware that INCR followed by EXPIRE is not atomic use transactions for critical implementations - Set the Retry-After header on every 429 response - Clean up old rate limit data automatically using Redis expiry - Implement progressive delays for a better legitimate user experience - Log rate limit violations for security monitoring For Laravel: - Use named rate limiters for complex rules - Rate limit by both IP and email on login endpoints - Use RateLimiter::clear() to reset the counter on successful login - Set CACHE_DRIVER=redis in production - Rate limit password reset,
Comments
No comments yet. Start the discussion.