Introduction
Rate limiting prevents abuse by restricting how often actions can be performed.
Key Concepts
- Token Bucket Algorithm: A rate limiting strategy where tokens are added at a fixed rate and consumed per request.
- Sliding Window: Tracks request counts in overlapping time windows for smoother rate limit enforcement.
- HTTP 429 Too Many Requests: The standard status code for rate-limited responses, with
Retry-Afterheader. - Distributed Rate Limiting: Using Redis or Memcached to enforce rate limits across multiple application servers.
Real World Context
Rate limiting is critical for preventing brute-force login attacks, API abuse, and denial-of-service. GitHub's API limits unauthenticated requests to 60/hour and authenticated to 5,000/hour. Without rate limiting, a single attacker can overwhelm your login endpoint with credential stuffing attacks.
Deep Dive
Intro
Rate limiting prevents abuse by restricting how often actions can be performed.
Simple rate limiter
php<?php class RateLimiter { public function __construct( private PDO $pdo ) {} public function attempt( string $key, int $maxAttempts, int $windowSeconds ): bool { $now = time(); $windowStart = $now - $windowSeconds; // Clean old entries $stmt = $this->pdo->prepare( 'DELETE FROM rate_limits WHERE key = :key AND timestamp < :window' ); $stmt->execute(['key' => $key, 'window' => $windowStart]); // Count recent attempts $stmt = $this->pdo->prepare( 'SELECT COUNT(*) FROM rate_limits WHERE key = :key' ); $stmt->execute(['key' => $key]); $count = $stmt->fetchColumn(); if ($count >= $maxAttempts) { return false; // Rate limited } // Record this attempt $stmt = $this->pdo->prepare( 'INSERT INTO rate_limits (key, timestamp) VALUES (:key, :time)' ); $stmt->execute(['key' => $key, 'time' => $now]); return true; } } // Usage $limiter = new RateLimiter($pdo); $key = 'login:' . $_SERVER['REMOTE_ADDR']; if (!$limiter->attempt($key, maxAttempts: 5, windowSeconds: 300)) { http_response_code(429); die('Too many requests. Please wait 5 minutes.'); }
Token bucket with redis
php<?php class TokenBucketLimiter { public function __construct( private Redis $redis ) {} public function consume( string $key, int $capacity, int $refillRate, int $refillInterval = 1 ): bool { $now = microtime(true); $bucketKey = "bucket:$key"; $bucket = $this->redis->hGetAll($bucketKey); if (empty($bucket)) { $tokens = $capacity - 1; $lastRefill = $now; } else { $tokens = (float) $bucket['tokens']; $lastRefill = (float) $bucket['last_refill']; // Refill tokens based on time elapsed $elapsed = $now - $lastRefill; $refillAmount = floor($elapsed / $refillInterval) * $refillRate; $tokens = min($capacity, $tokens + $refillAmount); $lastRefill = $now; if ($tokens < 1) { return false; // No tokens available } $tokens--; } $this->redis->hMSet($bucketKey, [ 'tokens' => $tokens, 'last_refill' => $lastRefill, ]); $this->redis->expire($bucketKey, 3600); return true; } }
Common Pitfalls
- Rate limiting by IP only — Shared IPs (corporate NAT, mobile carriers) can affect legitimate users. Combine IP-based limits with user/API key-based limits.
- Not including Retry-After headers — Well-behaved clients need to know when they can retry. Always include the
Retry-Afterheader with 429 responses.
Best Practices
- Use Redis for distributed rate limiting — The
INCR+EXPIREpattern in Redis provides atomic, cross-server rate limiting with minimal overhead. - Apply tiered rate limits — Use different limits for different endpoints: stricter for login/password-reset, more generous for read-only API endpoints.
Summary
- Rate limiting prevents brute-force attacks, credential stuffing, and API abuse.
- Return HTTP 429 with a
Retry-Afterheader when limits are exceeded. - Use Redis-backed rate limiting for distributed applications with multiple servers.
Code Examples
php
<?php
declare(strict_types=1);
// Production rate limiter with different strategies
interface RateLimiterInterface {
public function attempt(string $key): bool;
public function getRemainingAttempts(string $key): int;
public function getRetryAfter(string $key): int;
}
class SlidingWindowLimiter implements RateLimiterInterface {
public function __construct(
private PDO $pdo,
private int $maxAttempts = 60,
private int $windowSeconds = 60
) {}
public function attempt(string $key): bool {
$now = time();
$windowStart = $now - $this->windowSeconds;
// Atomic operation with transaction
$this->pdo->beginTransaction();
try {
// Remove expired entries
$stmt = $this->pdo->prepare(
'DELETE FROM rate_limits WHERE `key` = :key AND created_at < :window'
);
$stmt->execute(['key' => $key, 'window' => $windowStart]);
// Count current window
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM rate_limits WHERE `key` = :key'
);
$stmt->execute(['key' => $key]);
$count = (int) $stmt->fetchColumn();
if ($count >= $this->maxAttempts) {
$this->pdo->commit();
return false;
}
// Record attempt
$stmt = $this->pdo->prepare(
'INSERT INTO rate_limits (`key`, created_at) VALUES (:key, :time)'
);
$stmt->execute(['key' => $key, 'time' => $now]);
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function getRemainingAttempts(string $key): int {
$windowStart = time() - $this->windowSeconds;
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM rate_limits WHERE `key` = :key AND created_at >= :window'
);
$stmt->execute(['key' => $key, 'window' => $windowStart]);
$count = (int) $stmt->fetchColumn();
return max(0, $this->maxAttempts - $count);
}
public function getRetryAfter(string $key): int {
$stmt = $this->pdo->prepare(
'SELECT MIN(created_at) FROM rate_limits WHERE `key` = :key'
);
$stmt->execute(['key' => $key]);
$oldest = $stmt->fetchColumn();
if (!$oldest) {
return 0;
}
return max(0, ($oldest + $this->windowSeconds) - time());
}
}
// Middleware usage
function rateLimitMiddleware(RateLimiterInterface $limiter): void {
$key = 'api:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!$limiter->attempt($key)) {
$retryAfter = $limiter->getRetryAfter($key);
header('Retry-After: ' . $retryAfter);
header('X-RateLimit-Remaining: 0');
http_response_code(429);
die(json_encode(['error' => 'Too Many Requests']));
}
header('X-RateLimit-Remaining: ' . $limiter->getRemainingAttempts($key));
}
?>