PHP Rate Limiting: Stop Abuse Before You Need CAPTCHAs
Rate limiting controls how many requests a user can make in a given time window. It's the most effective server-side defense against brute force attacks, credential stuffing, and form spam — and unlike CAPTCHAs, it adds zero user friction until the limit is hit.
Every PHP form that handles login, registration, password reset, or payment should have rate limiting. Here's how to implement it properly.
Three Algorithms, One Goal
| Algorithm | How It Works | Best For | Complexity |
|---|---|---|---|
| Fixed Window | Count requests in fixed time slots (e.g., per minute) | Simple API limits, contact forms | Easy |
| Sliding Window | Rolling time window tracks exact request timestamps | Login endpoints, where burst control matters | Medium |
| Token Bucket | Tokens refill at a steady rate; each request costs a token | APIs with burst allowance, webhook endpoints | Medium |
IP Normalization
Rate limiting by individual IPv6 address is ineffective — attackers rotate through their entire /64 prefix. Normalize to subnet:
<?php
function normalizeIp(string $ip): string
{
// Strip port numbers and trim whitespace
$ip = trim(explode(',', $ip)[0]);
$ip = preg_replace('/:\d+$/', '', $ip);
// IPv6: rate limit by /64 subnet
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$packed = inet_pton($ip);
// Zero out the last 8 bytes (keep /64 prefix)
$masked = substr($packed, 0, 8) . str_repeat("\0", 8);
return inet_ntop($masked) . '/64';
}
return $ip;
}
Implementation 1: Redis Sliding Window (Recommended)
Redis is the standard for production rate limiting: atomic operations, sub-millisecond latency, and natural TTL-based cleanup. This sliding window implementation tracks exact timestamps for precise burst control.
<?php
// Redis sliding window rate limiter — PHP 8.0+
class RedisRateLimiter
{
public function __construct(
private \Redis $redis,
private int $maxRequests = 10,
private int $windowSeconds = 60,
) {}
public function isAllowed(string $key): bool
{
$now = microtime(true);
$windowStart = $now - $this->windowSeconds;
$member = $now . ':' . bin2hex(random_bytes(4));
// Atomic pipeline: remove expired, add current, count, set TTL
$this->redis->multi(\Redis::PIPELINE);
$this->redis->zRemRangeByScore($key, '-inf', (string) $windowStart);
$this->redis->zAdd($key, $now, $member);
$this->redis->zCard($key);
$this->redis->expire($key, $this->windowSeconds + 1);
$results = $this->redis->exec();
$count = $results[2] ?? 0;
return $count <= $this->maxRequests;
}
public function getRemaining(string $key): int
{
$windowStart = microtime(true) - $this->windowSeconds;
$this->redis->zRemRangeByScore($key, '-inf', (string) $windowStart);
$count = $this->redis->zCard($key);
return max(0, $this->maxRequests - $count);
}
}
// Usage
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$limiter = new RedisRateLimiter(
redis: $redis,
maxRequests: 5, // 5 submissions
windowSeconds: 300, // per 5 minutes
);
// Default: trust only REMOTE_ADDR (safe)
$clientIp = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
// If behind a TRUSTED reverse proxy (Cloudflare, nginx, etc.),
// uncomment ONE of these lines — but ONLY if your server validates
// the proxy source IP:
// $clientIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $clientIp; // Cloudflare
// $clientIp = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')[0] ?? $clientIp; // Generic proxy
// Normalize IPv6 to /64 subnet
$ip = normalizeIp($clientIp);
$key = "rate_limit:form_submit:{$ip}";
if (!$limiter->isAllowed($key)) {
http_response_code(429);
header('Retry-After: 300');
$errors[] = 'Too many submissions. Please wait a few minutes.';
}
Implementation 2: APCu (No Redis Required)
If you don't have Redis, APCu (PHP's user cache) works for single-server deployments. It's faster than file-based approaches and handles concurrency correctly.
<?php
// APCu fixed-window rate limiter — PHP 8.0+
function isRateLimitedApcu(
string $key,
int $maxRequests = 5,
int $windowSeconds = 300,
): bool {
$cacheKey = "rate:{$key}";
// apcu_inc creates the key atomically if it doesn't exist (with TTL)
$count = apcu_inc($cacheKey, 1, $success, $windowSeconds);
if (!$success) {
// Shouldn't happen with TTL param, but handle gracefully
apcu_store($cacheKey, 1, $windowSeconds);
return false;
}
return $count > $maxRequests;
}
// Usage (same IP normalization as above)
if (isRateLimitedApcu("form:{$ip}")) {
http_response_code(429);
header('Retry-After: 300');
$errors[] = 'Too many submissions. Please wait.';
}
Limitation: APCu is per-process and doesn't share state across multiple PHP-FPM pools or servers. For multi-server deployments, use Redis.
Implementation 3: Token Bucket (Burst-Friendly)
Token bucket allows short bursts while enforcing an average rate. Users get a "bucket" of tokens; each request costs one. Tokens refill at a steady rate.
Important: This implementation is simplified for clarity. In production with high concurrency, wrap the read-modify-write in a Redis Lua script or use WATCH/MULTI for atomicity. The sliding window implementation above uses atomic pipeline operations and is safer under load.
<?php
// Redis token bucket — allows bursts, enforces average rate
class TokenBucketLimiter
{
public function __construct(
private \Redis $redis,
private int $capacity = 10, // Max burst size
private float $refillRate = 0.5, // Tokens per second
) {}
public function consume(string $key, int $tokens = 1): bool
{
$now = microtime(true);
$data = $this->redis->hGetAll($key);
$currentTokens = (float) ($data['tokens'] ?? $this->capacity);
$lastRefill = (float) ($data['last_refill'] ?? $now);
// Refill tokens based on elapsed time
$elapsed = $now - $lastRefill;
$currentTokens = min(
$this->capacity,
$currentTokens + ($elapsed * $this->refillRate)
);
if ($currentTokens < $tokens) {
return false; // Not enough tokens
}
// Consume tokens and update state
$this->redis->hMSet($key, [
'tokens' => $currentTokens - $tokens,
'last_refill' => $now,
]);
$this->redis->expire($key, (int) ($this->capacity / $this->refillRate) + 60);
return true;
}
}
// Usage: 10-request burst, refills at 1 request per 10 seconds
$bucket = new TokenBucketLimiter($redis, capacity: 10, refillRate: 0.1);
if (!$bucket->consume("api:{$ip}")) {
http_response_code(429);
header('Retry-After: 10');
$errors[] = 'Rate limit exceeded.';
}
When to use token bucket: API endpoints where legitimate users sometimes send bursts (form submissions with retries, webhook receivers). Sliding window is better for login endpoints where you want strict per-minute limits.
Response Headers
Well-behaved rate limiters tell clients their status. Add these headers to every response:
<?php
// Standard rate limit response headers
function setRateLimitHeaders(int $limit, int $remaining, int $resetTimestamp): void
{
header("X-RateLimit-Limit: {$limit}");
header("X-RateLimit-Remaining: {$remaining}");
header("X-RateLimit-Reset: {$resetTimestamp}");
}
// On 429 responses, include Retry-After
if ($rateLimited) {
http_response_code(429);
header('Retry-After: 300');
setRateLimitHeaders($maxRequests, 0, time() + 300);
}
Common Mistakes
Trusting X-Forwarded-For
Clients can forge X-Forwarded-For. Only trust it if you control the proxy chain. Behind Cloudflare, use HTTP_CF_CONNECTING_IP. Behind your own nginx, configure set_real_ip_from to trust only your proxy's IP.
File-Based Storage
File-based rate limiting (reading/writing JSON files per IP) has race conditions under concurrent requests. Two simultaneous requests can read the same count, both pass the limit, and both write. Use atomic operations (Redis INCR, APCu apcu_inc) instead.
Blocking Shared IPs
Corporate offices, universities, and mobile carriers share IPs. A rate limit of 5 requests per 5 minutes might block the second person at a company who tries to use your form. Set limits generously for contact forms (10-20 per window) and tightly for security-sensitive endpoints (3-5 login attempts).
Integrating Rate Limiting with CAPTCHAs
Rate limiting and CAPTCHAs work best together. Use rate limiting as the first line of defense, and escalate to a CAPTCHA when limits are approached:
<?php
// Escalating defense: rate limit → CAPTCHA → block
$remaining = $limiter->getRemaining($key);
if ($remaining <= 0) {
// Hard block
http_response_code(429);
exit;
} elseif ($remaining <= 2) {
// Approaching limit — require CAPTCHA verification
$captchaValid = verifyTurnstile($_POST['cf-turnstile-response'] ?? '', $secret);
if (!$captchaValid) {
$errors[] = 'Please complete the verification.';
}
}
// else: under limit, no CAPTCHA needed
This approach adds zero friction to normal users while making automated attacks expensive. See our PHP form spam protection guide for the full layered defense strategy, or our Turnstile integration guide for the CAPTCHA setup.
Verdict
Rate limiting is the highest-value, lowest-friction security measure you can add to any PHP form. Redis sliding window is the production standard. APCu works for single-server setups. Either one takes less time to implement than configuring a CAPTCHA and produces fewer false positives.
For login pages: 5 attempts per 15 minutes, then block. For contact forms: 10 submissions per 5 minutes, then escalate to CAPTCHA verification. For APIs: token bucket with generous burst capacity. Start with rate limiting, add a CAPTCHA only when you need it.