DEV Community

Cover image for Rate Limiting in Laravel and PHP — How to Stop Brute Force Before It Starts

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] <= $maxAttempts;
}

if (!checkRateLimit(5, 60)) {
    http_response_code(429);
    header('Retry-After: 60');
    die('Too many attempts. Please try again in 60 seconds.');
}
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

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:' . $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.');
}
Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()),
    ];
});
Enter fullscreen mode Exit fullscreen mode
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);
});
Enter fullscreen mode Exit fullscreen mode

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.']);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 user@example.com'
], 429);

// Correct — generic with retry timing
return response()->json([
    'message' => 'Too many attempts. Please try again later.',
    'retry_after' => $seconds
], 429);
Enter fullscreen mode Exit fullscreen mode

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, 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

Where Kriosa Fits

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.
production PHP applications are already running with Kriosa in front of them.

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
Install it: 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.
The Series So Far

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

Top comments (0)