PHP Rate Limiter that stops Form Spam & Endpoint Abuse
A tiny abuse shield you can drop into any PHP site to limit how often a visitor can hit a page or endpoint (contact forms, login, like buttons, vote/poll scripts, search, etc.). Most people only think about this after they get hammered—then it’s suddenly urgent.
What it does:
- Limits requests per IP (or per custom identity) inside a time window (example: 10 requests per 60 seconds).
- Returns a clean
429 Too Many Requestsresponse when the limit is exceeded. - Sends
Retry-Afterso well-behaved clients know when to try again. - Uses simple file storage (shared-host friendly) with safe file locking—no database needed.
Install (newbie-friendly):
- Create a file named
rate_limit.phpand paste the code below into it. - Upload it somewhere your scripts can access (example:
/inc/rate_limit.php). - At the TOP of any endpoint you want to protect, add:
require __DIR__ . '/inc/rate_limit.php';rl_enforce('contact-form', 5, 60);(means 5 hits per 60 seconds per IP)
Examples:
- Contact form:
rl_enforce('contact', 3, 120);(3 submits every 2 minutes) - Login:
rl_enforce('login', 10, 600);(10 attempts every 10 minutes) - Like button / vote endpoint:
rl_enforce('like', 30, 60);(30/minute)
Tip: You can also rate-limit by a custom identity (like user id or email hash) instead of IP—see the $identity parameter in the code.
<?php
declare(strict_types=1);
/**
* Tiny PHP Rate Limiter (No DB)
* - Shared-host friendly (file-based)
* - Safe file locking
* - Returns 429 + Retry-After when exceeded
*
* Usage:
* require __DIR__ . '/rate_limit.php';
* rl_enforce('contact', 3, 120); // 3 per 2 minutes per IP
* rl_enforce('login', 10, 600); // 10 per 10 minutes per IP
*
* Advanced:
* rl_enforce('reset-password', 5, 900, $identity = 'user:'.$userId);
*/
// Where counters are stored (must be writable). Default: system temp folder.
if (!defined('RL_DIR')) {
define('RL_DIR', rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'php_rate_limits');
}
// If you are behind a reverse proxy/CDN and you TRUST its IPs, you can allow reading X-Forwarded-For.
// Keep this OFF by default (safer on most shared hosts).
if (!defined('RL_TRUST_PROXY')) {
define('RL_TRUST_PROXY', false);
}
// Optional: if RL_TRUST_PROXY is true, set a list of trusted proxy IPs (strings).
// Example: define('RL_TRUSTED_PROXIES', ['203.0.113.10', '203.0.113.11']);
if (!defined('RL_TRUSTED_PROXIES')) {
define('RL_TRUSTED_PROXIES', []);
}
function rl_client_ip(): string {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!RL_TRUST_PROXY) return $ip;
// Only trust forwarded headers if the connecting IP is a trusted proxy.
if (!in_array($ip, RL_TRUSTED_PROXIES, true)) return $ip;
// Try X-Forwarded-For (first IP is original client)
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
if ($xff !== '') {
$parts = array_map('trim', explode(',', $xff));
if (!empty($parts[0]) && filter_var($parts[0], FILTER_VALIDATE_IP)) return $parts[0];
}
$rip = $_SERVER['HTTP_X_REAL_IP'] ?? '';
if ($rip !== '' && filter_var($rip, FILTER_VALIDATE_IP)) return $rip;
return $ip;
}
function rl_init_dir(): void {
if (is_dir(RL_DIR)) return;
// Attempt to create (0700 keeps it private where supported)
@mkdir(RL_DIR, 0700, true);
// If it still doesn't exist, we'll fail later when writing.
}
function rl_key_to_path(string $scope, int $windowSeconds, string $identity): string {
// Normalize scope to keep filenames clean
$scope = preg_replace('/[^a-zA-Z0-9:_-]+/', '-', $scope) ?? 'scope';
$bucket = (int)floor(time() / max(1, $windowSeconds)); // fixed window bucket
$hash = hash('sha256', $scope . '|' . $windowSeconds . '|' . $bucket . '|' . $identity);
return RL_DIR . DIRECTORY_SEPARATOR . $scope . '__' . $hash . '.json';
}
/**
* Returns [allowed(bool), retry_after(int seconds)]
*/
function rl_check(string $scope, int $maxHits, int $windowSeconds, ?string $identity = null): array {
$maxHits = max(1, (int)$maxHits);
$windowSeconds = max(1, (int)$windowSeconds);
rl_init_dir();
$identity = $identity ?? ('ip:' . rl_client_ip());
$path = rl_key_to_path($scope, $windowSeconds, $identity);
// Open or create the file. 'c+' = read/write, create if missing.
$fh = @fopen($path, 'c+');
if (!$fh) {
// If storage fails, fail-open (do not break the site),
// but you can change this to fail-closed if you prefer.
return [true, 0];
}
$allowed = true;
$retryAfter = 0;
// Lock it so concurrent requests don't race.
if (!flock($fh, LOCK_EX)) {
fclose($fh);
return [true, 0];
}
// Read existing state
$raw = '';
clearstatcache(true, $path);
$size = filesize($path);
if ($size && $size > 0 && $size < 1024 * 64) {
rewind($fh);
$raw = (string)fread($fh, $size);
}
$now = time();
$state = ['reset' => $now + $windowSeconds, 'count' => 0];
if ($raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded) && isset($decoded['reset'], $decoded['count'])) {
$state['reset'] = (int)$decoded['reset'];
$state['count'] = (int)$decoded['count'];
}
}
// If expired window, reset.
if ($now >= $state['reset']) {
$state['reset'] = $now + $windowSeconds;
$state['count'] = 0;
}
// Count this hit
$state['count']++;
if ($state['count'] > $maxHits) {
$allowed = false;
$retryAfter = max(1, $state['reset'] - $now);
}
// Write back
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, json_encode($state, JSON_UNESCAPED_SLASHES));
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
return [$allowed, $retryAfter];
}
/**
* Enforce the limit and exit with 429 if exceeded.
*/
function rl_enforce(string $scope, int $maxHits, int $windowSeconds, ?string $identity = null, string $message = 'Too many requests. Please try again shortly.'): void {
[$ok, $retryAfter] = rl_check($scope, $maxHits, $windowSeconds, $identity);
if ($ok) return;
http_response_code(429);
header('Content-Type: text/plain; charset=utf-8');
header('Retry-After: ' . $retryAfter);
echo $message;
exit;
}
?>
Comments (0)
No comments yet — be the first.