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 parameteriz

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. 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. 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] <= $maxAttempts; } if (!checkRateLimit(5, 60)) { http_response_code(429); header('Retry-After: 60'); die('Too many attempts. Please try again in 60 seconds.'); } Session-based rate limiting is not meaningful brute-force protection. An attacker can create or discard sessions repeatedly to reset the counter. Automated tools that do not maintain sessions bypass it entirely. Use it only as a demonstration not as real protection. Database-based — reliable for moderate traffic: // Required table // CREATE TABLE login_attempts ( // id INT AUTO_INCREMENT PRIMARY KEY, // identifier VARCHAR(255) NOT NULL, // attempted_at INT NOT NULL, // INDEX idx_identifier_time (identifier, attempted_at) // ); function checkRateLimitDb( PDO $pdo, string $identifier, int $maxAttempts, int $windowSeconds ): bool { $now = time(); $windowStart = $now - $windowSeconds; $stmt = $pdo->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 <= $maxAttempts; } $redis = new Redis(); $redis->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:' . $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 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:' . $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 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. 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. 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 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 user@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. 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, registration, OTP, and 2FA endpoints Never reveal which rate limit factor was triggered in error messages Check your Laravel version for the correct location of rate limiter configuration Rate limiting at the application layer controls how many requests reach your login logic. But it has gaps. A credential stuffing attack where each compromised credential is tried only once never triggers a rate limit because one attempt per account is never flagged. An attacker using a large botnet can stay under per-IP rate limits while still making thousands of attempts across your user base. These are the attacks that application-level rate limiting cannot see because each individual request looks legitimate. Kriosa monitors incoming request patterns for behavioral signals that individual rate limiters cannot surface flagging suspicious activity in the XAI dashboard with an explanation of what was detected and why. Prolify — a design-proofing platform where designers share work with clients for review and approval. Every session is a trust boundary. A compromised login means a client's unreleased creative work is exposed to the wrong person. belle full — a PHP application built for bakers, handling real customer orders and business data. Secured with Kriosa from day one not retrofitted after a scare, built with protection as a foundation. Both applications implement application-level rate limiting on their login and sensitive endpoints. That handles the obvious attacks a single IP hammering a login form, a script cycling through common passwords. What rate limiting cannot see is the subtler pattern. One attempt against one account from one IP looks like a normal failed login. One thousand attempts spread across one thousand accounts from one thousand IPs each individually normal looks like nothing to a rate limiter. To Kriosa it looks like a coordinated attack. That is the layer Prooflify and Belle-Full have that rate limiting alone cannot provide. Visibility into what is happening across the application not just at each individual endpoint. Rate limiting controls the rate. Kriosa helps you understand the pattern. Try it free: kriosa.com composer require kriosa-ai/kriosa-php Documentation: kriosa Docs. Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it. Article 1: What your PHP logs actually look like during a SQL injection attack Article 2: Why URL encoding can break PHP security checks Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability Article 4: Parameterized queries — the only real fix for SQL injection Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked Article 6: How attackers enumerate your Laravel app before exploiting it Article 7: File upload security in PHP and Laravel Article 8: Path traversal in PHP — how ../ escapes your application Article 9: Command injection in PHP — when exec() becomes an attack surface Article 10: Broken access control in Laravel — why being logged in is not enough Article 11: Secrets in Laravel — why .env is only the beginning Article 12: Session security in PHP — what most developers get wrong Article 13: This article — rate limiting in Laravel and PHP and how to stop brute force before it starts
Key Takeaways
- •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 parameteriz
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


