PHP Rate Limiter that stops Form Spam & Endpoint Abuse

December 28, 2025

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 Requests response when the limit is exceeded.
  • Sends Retry-After so 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):

  1. Create a file named rate_limit.php and paste the code below into it.
  2. Upload it somewhere your scripts can access (example: /inc/rate_limit.php).
  3. 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.

← Back to all scripts