How to Build a Custom Rate Limiter in PHP Using Redis to Stop Brute Force Attacks

By Admin • Updated September 17, 2026 • 58 Reads
PHP custom rate limiter using Redis to stop brute force login attacks
Custom PHP and Redis Rate Limiter Architecture

Public authentication endpoints, password reset forms, SMS verification gateways, and REST API routes are constant targets for automated credential stuffing, distributed brute-force scripts, and denial-of-service attempts. Naive implementations frequently log every failed attempt into relational database tables like MySQL or PostgreSQL. Under a sustained high-concurrency attack, the resulting disk I/O bottlenecks and table row locking will rapidly exhaust your database connection pool and bring down your entire production stack.

To protect backend services without sacrificing throughput, high-throughput architectures offload request throttling to an in-memory datastore like Redis. Redis operates entirely in RAM and executes atomic primitive operations in sub-millisecond latencies. In this comprehensive guide, we will evaluate rate limiting algorithms, design an atomic sliding-window rate limiter in native PHP 8.x using Redis and Lua scripting, implement standard IETF HTTP 429 response headers, and construct a fail-safe fallback circuit breaker for production resilience.

1. Rate Limiting Algorithms: Fixed Window vs. Sliding Window vs. Token Bucket

Choosing the right rate-limiting algorithm depends on your tolerance for burst traffic and memory overhead:

Fixed Window Counter

Counts requests in fixed blocks of time (e.g., 60 seconds starting on the minute). Easiest to implement using simple INCR and EXPIRE.

Vulnerability: Boundary burst. A client can send 100% of their quota in the last second of Minute 1 and 100% in the first second of Minute 2, doubling the permitted throughput across the window boundary.

Sliding Window Log

Stores every individual request timestamp in a Redis Sorted Set (ZSET). Accurately measures the exact rolling duration (now minus 60 seconds).

Benefit: Absolute mathematical precision. Eliminates boundary bursts completely. Higher memory usage for very high quotas.

Token Bucket

Tokens accumulate in a bucket at a constant refill rate up to a max capacity. Each request consumes one token.

Benefit: Permits controlled bursts while maintaining a strict long-term average consumption rate.

2. The Concurrency Trap: Why Naive Redis PHP Scripts Fail

Many beginner tutorials demonstrate Redis rate limiting using separate sequential PHP calls:

// BROKEN: Race condition under concurrent requests
$count = $redis->get($key);
if ($count >= $limit) {
    deny_request();
}
$redis->incr($key);
$redis->expire($key, 60);

Under high concurrency, if 50 requests from a botnet arrive simultaneously within the same 5 milliseconds, all 50 PHP processes read $count = 0 simultaneously before any single process executes incr(). As a result, all 50 malicious requests bypass the rate limiter completely.

To achieve true thread-safety, operations must either use atomic multi-key commands or be executed as an atomic Lua script evaluated directly on the Redis server engine.

3. Building the Production Sliding Window Rate Limiter with Lua

The sliding-window log algorithm using Redis Sorted Sets (ZSET) guarantees that an attacker cannot exploit boundary windows. We package the log evaluation into a Lua script that executes atomically in Redis:

<?php
declare(strict_types=1);

namespace App\Security;

use Redis;
use RuntimeException;

class RedisRateLimiter
{
    private Redis $redis;

    // Lua script: Atomic Sliding Window Log
    private const LUA_SLIDING_WINDOW = <<<'LUA'
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window = tonumber(ARGV[2])
        local limit = tonumber(ARGV[3])
        local clearBefore = now - window

        -- 1. Remove all request timestamps older than the rolling window
        redis.call('ZREMRANGEBYSCORE', key, 0, clearBefore)

        -- 2. Count requests remaining in the current window
        local currentCount = redis.call('ZCARD', key)

        if currentCount < limit then
            -- 3. Add current request unique timestamp to sorted set
            redis.call('ZADD', key, now, now .. '-' .. redis.call('INCR', key .. ':seq'))
            -- 4. Set key TTL so inactive sets expire automatically
            redis.call('EXPIRE', key, math.ceil(window / 1000) + 5)
            redis.call('EXPIRE', key .. ':seq', math.ceil(window / 1000) + 5)
            return {1, limit - currentCount - 1, 0}
        else
            -- 5. Calculate oldest timestamp to determine Retry-After
            local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
            local resetTime = 0
            if #oldest > 0 then
                resetTime = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
            end
            if resetTime < 1 then resetTime = 1 end
            return {0, 0, resetTime}
        end
LUA;

    public function __construct(string $host = '127.0.0.1', int $port = 6379, ?string $auth = null)
    {
        $this->redis = new Redis();
        if (!$this->redis->connect($host, $port, 1.5)) {
            throw new RuntimeException("Could not connect to Redis host: {$host}");
        }
        if ($auth !== null) {
            $this->redis->auth($auth);
        }
    }

    /**
     * Inspects quota for given identifier
     *
     * @param string $identifier IP address, API key, or user UUID
     * @param int $maxAttempts Maximum allowed requests
     * @param int $windowSeconds Rolling timeframe in seconds
     * @return array [allowed => bool, remaining => int, retry_after => int]
     */
    public function checkQuota(string $identifier, int $maxAttempts = 60, int $windowSeconds = 60): array
    {
        $key = "ratelimit:" . hash('sha256', $identifier);
        $now = (int)(microtime(true) * 1000); // Microseconds for uniqueness
        $windowMs = $windowSeconds * 1000;

        try {
            $result = $this->redis->eval(
                self::LUA_SLIDING_WINDOW,
                [$key, $now, $windowMs, $maxAttempts],
                1
            );

            return [
                'allowed'     => (bool)$result[0],
                'remaining'   => (int)$result[1],
                'retry_after' => (int)$result[2]
            ];
        } catch (\Throwable $e) {
            // Circuit Breaker: Fail-Open to preserve site availability if Redis fails
            error_log("RateLimiter Redis failure: " . $e->getMessage());
            return [
                'allowed'     => true,
                'remaining'   => 1,
                'retry_after' => 0
            ];
        }
    }
}

4. Emitting Standard IETF HTTP 429 Headers

When rejecting throttled requests, conforming to modern IETF HTTP specifications (RFC 6585) allows well-behaved API clients, mobile apps, and frontend single-page apps to gracefully delay retries rather than slamming the server in an infinite retry loop:

<?php
// Protect public login endpoint
$limiter = new App\Security\RedisRateLimiter('127.0.0.1', 6379);

// Combine IP + Route to avoid throttling whole corporate networks
$clientIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$routeIdentifier = "login_attempt:" . $clientIp;

// Allow max 5 failed logins per 15 minutes (900 seconds)
$quota = $limiter->checkQuota($routeIdentifier, 5, 900);

header('X-RateLimit-Limit: 5');
header('X-RateLimit-Remaining: ' . $quota['remaining']);

if (!$quota['allowed']) {
    http_response_code(429); // 429 Too Many Requests
    header('Retry-After: ' . $quota['retry_after']);
    header('Content-Type: application/json; charset=UTF-8');
    
    echo json_encode([
        'error'       => 'Too Many Requests',
        'message'     => 'Rate limit exceeded. Please wait before attempting to sign in again.',
        'retry_after' => $quota['retry_after'] . ' seconds'
    ]);
    exit;
}

// Proceed to authenticate user credentials...

5. Production Resilience: Fail-Open vs. Fail-Closed Strategies

In enterprise infrastructure, you must make a deliberate architectural decision: what happens if the Redis instance crashes, runs out of memory, or suffers a network partition?

  • Fail-Open (Recommended for E-Commerce & Content): If Redis is unreachable, the rate limiter catches the connection exception, logs an alert to your APM (e.g., Datadog or Sentry), and permits the incoming request. Your customers can still complete purchases even if rate limiting is temporarily degraded.
  • Fail-Closed (Recommended for High-Value Financial & Crypto APIs): If Redis fails, all authentication and funds-transfer endpoints immediately reject requests with HTTP 503 Service Unavailable. This guarantees that an attacker cannot intentionally trigger a denial-of-service on Redis to bypass brute-force locks.

Summary: Security Checklist

  • Always hash client identifiers: Avoid storing raw IP addresses directly as Redis keys to comply with GDPR and privacy mandates.
  • Use Cloudflare or Reverse Proxy headers correctly: Never rely on $_SERVER['REMOTE_ADDR'] if running behind a load balancer; validate proxy headers against trusted upstream CIDR blocks.
  • Always set key TTLs: Ensure every temporary key in Redis has a strict expiration time so memory usage remains constant over time.