Andy Hinkle

Andy Hinkle

Laravel Developer


December 14, 2025

Auto-Ban Malicious IPs on Cloudflare with Laravel

If you're running a Laravel site behind Cloudflare, you've probably noticed something frustrating in your logs: waves of 404 errors from bots probing for WordPress vulnerabilities, React vulnerabilities, or other various paths. Requests to /wp-admin, /wp-login.php, /wp-includes - even though you're not running WordPress or React.

Cloudflare's WAF rules can block these requests, but here's the catch: WAF rules are path-specific, not blanket blocks. When a rule triggers on /wp-admin, it blocks that specific path while the same visitor can still access /, /about, or any other page. The bot gets a 403 on the WordPress path, shrugs, and continues probing other URLs.

The result? Your analytics can fill with 404 errors from the same malicious IPs, skewing your analytics and making it harder to spot real issues. Your server still processes these requests, wasting resources, and you consume Nightwatch credits or other monitoring tools that mark these requests as failures.

Cloudflare offers IP blocking, but manually banning IPs is tedious and reactive. You can view a sample of traffic by visiting Security → Analytics in Cloudflare, but this only shows a fraction of the traffic and requires manual intervention. Here's an example of one such IP repeatedly hitting various non-existent paths:

Malicious Bot Probing

Let's fix that by automatically banning IPs at the Cloudflare level when they exhibit malicious behavior.

The Strategy

We'll build a Laravel middleware that:

  1. Immediately bans any IP requesting WordPress-related paths
  2. Tracks 404 errors per IP address using Laravel's cache
  3. Auto-bans any IP that triggers 5+ 404s within 120 seconds
  4. Updates Cloudflare's WAF via a queued job to add the IP to a block rule

The beauty of this approach is that once an IP is banned at Cloudflare's edge, they never even reach your server again.

Step 1: Set Up Cloudflare

First, you'll need a WAF Custom Rule in Cloudflare to hold your banned IPs.

  1. Go to your Cloudflare dashboard → Security → WAF → Custom Rules
  2. Create a new rule with these settings:
    • Rule name: IP Ban
    • Expression: (ip.src eq 0.0.0.0) (we'll update this dynamically)
    • Action: Block
  3. Note the Zone ID, Ruleset ID, and Rule ID from the URL and API

Create an API Token with Zone → Zone WAF → Read and Zone → Zone WAF → Edit permissions.

Add these to your .env:

1CLOUDFLARE_ZONE=your-zone-id
2CLOUDFLARE_RULESET=your-ruleset-id
3CLOUDFLARE_RULE=your-rule-id
4CLOUDFLARE_TOKEN=your-api-token

And register them in config/services.php:

1'cloudflare' => [
2 'zone_id' => env('CLOUDFLARE_ZONE'),
3 'ruleset_id' => env('CLOUDFLARE_RULESET'),
4 'rule_id' => env('CLOUDFLARE_RULE'),
5 'token' => env('CLOUDFLARE_TOKEN'),
6],

Step 2: Create the Ban Job

This queued job handles the Cloudflare API interaction. It fetches the current rule, appends the new IP to the expression, and updates the rule.

1php artisan make:job BanIpOnCloudflare
1<?php
2 
3namespace App\Jobs;
4 
5use Illuminate\Contracts\Queue\ShouldQueue;
6use Illuminate\Foundation\Queue\Queueable;
7use Illuminate\Support\Facades\Config;
8use Illuminate\Support\Facades\Http;
9use Illuminate\Support\Facades\Log;
10 
11class BanIpOnCloudflare implements ShouldQueue
12{
13 use Queueable;
14 
15 public function __construct(
16 public string $ip,
17 public string $reason,
18 ) {}
19 
20 public function handle(): void
21 {
22 $expression = $this->fetchCurrentExpression();
23 
24 $updatedExpression = "{$expression} or (ip.src eq {$this->ip})";
25 
26 $this->updateRule($updatedExpression);
27 
28 Log::info("Banned IP {$this->ip} on Cloudflare", [
29 'ip' => $this->ip,
30 'reason' => $this->reason,
31 ]);
32 }
33 
34 private function fetchCurrentExpression(): string
35 {
36 $response = Http::withToken($this->token())
37 ->throw()
38 ->get($this->rulesetUrl());
39 
40 $rules = $response->json('result.rules', []);
41 
42 foreach ($rules as $rule) {
43 if ($rule['id'] === $this->ruleId()) {
44 return $rule['expression'];
45 }
46 }
47 
48 return '(ip.src eq 0.0.0.0)';
49 }
50 
51 private function updateRule(string $expression): void
52 {
53 Http::withToken($this->token())
54 ->patch($this->ruleUrl(), [
55 'action' => 'block',
56 'description' => 'IP Ban',
57 'enabled' => true,
58 'expression' => $expression,
59 ])
60 ->throw();
61 }
62 
63 private function rulesetUrl(): string
64 {
65 $zoneId = Config::string('services.cloudflare.zone_id');
66 $rulesetId = Config::string('services.cloudflare.ruleset_id');
67 
68 return "https://api.cloudflare.com/client/v4/zones/{$zoneId}/rulesets/{$rulesetId}";
69 }
70 
71 private function ruleUrl(): string
72 {
73 return "{$this->rulesetUrl()}/rules/{$this->ruleId()}";
74 }
75 
76 private function ruleId(): string
77 {
78 return Config::string('services.cloudflare.rule_id');
79 }
80 
81 private function token(): string
82 {
83 return Config::string('services.cloudflare.token');
84 }
85}

The expression format Cloudflare uses is (ip.src eq X) or (ip.src eq Y). We simply append new IPs to the existing expression.

Step 3: Create the Middleware

Now for the middleware that detects malicious behavior and dispatches the ban job.

1php artisan make:middleware BanMaliciousIpMiddleware
1<?php
2 
3namespace App\Http\Middleware;
4 
5use App\Jobs\BanIpOnCloudflare;
6use Closure;
7use Illuminate\Http\Request;
8use Illuminate\Support\Facades\App;
9use Illuminate\Support\Facades\Cache;
10use Symfony\Component\HttpFoundation\Response;
11 
12class BanMaliciousIpMiddleware
13{
14 private const int MAX_404_COUNT = 5;
15 
16 private const int WINDOW_SECONDS = 120;
17 
18 /**
19 * @param Closure(Request): Response $next
20 */
21 public function handle(Request $request, Closure $next): Response
22 {
23 if (! App::isProduction()) {
24 return $next($request);
25 }
26 
27 if ($this->isWordPressProbe($request)) {
28 $this->banIp((string) $request->ip(), 'WordPress probe detected');
29 
30 abort(403);
31 }
32 
33 $response = $next($request);
34 
35 if ($response->isNotFound()) {
36 $this->trackNotFound($request);
37 }
38 
39 return $response;
40 }
41 
42 private function isWordPressProbe(Request $request): bool
43 {
44 return collect(['wp-admin', 'wp-login', 'wp-includes'])
45 ->contains(fn (string $probe) => str_contains($request->path(), $probe));
46 }
47 
48 private function trackNotFound(Request $request): void
49 {
50 $ip = (string) $request->ip();
51 $key = "ip_404_count:{$ip}";
52 
53 $count = Cache::increment($key);
54 
55 if ($count === 1) {
56 Cache::put($key, 1, self::WINDOW_SECONDS);
57 }
58 
59 if ($count >= self::MAX_404_COUNT) {
60 $this->banIp($ip, "Exceeded 404 threshold ({$count} in ".self::WINDOW_SECONDS.' seconds)');
61 
62 Cache::forget($key);
63 }
64 }
65 
66 private function banIp(string $ip, string $reason): void
67 {
68 BanIpOnCloudflare::dispatch($ip, $reason);
69 }
70}

Let's break down what this does:

  • Production only - We skip all tracking in local/staging to avoid accidentally banning ourselves
  • WordPress probes - Immediate ban and 403 response for any request containing wp-admin, wp-login, or wp-includes
  • 404 tracking - We increment a counter in cache for each IP that triggers a 404, a strong indication of probing behavior when done repeatedly
  • Threshold ban - If an IP hits 5+ 404s within 120 seconds, we ban them

Step 4: Register the Middleware

Add the middleware to your bootstrap/app.php:

1use App\Http\Middleware\BanMaliciousIpMiddleware;
2 
3return Application::configure(basePath: dirname(__DIR__))
4 ->withMiddleware(function (Middleware $middleware) {
5 $middleware->append([
6 BanMaliciousIpMiddleware::class,
7 ]);
8 })
9 // ...

The Result

Now when a malicious bot hits your site:

  1. If they request /wp-admin → Immediate 403 + banned on Cloudflare
  2. If they probe random URLs and trigger 5+ 404s → Banned on Cloudflare
  3. All subsequent requests from that IP are blocked at Cloudflare's edge

Your logs stay clean. Your analytics stay accurate. And the bots never reach your server again.

Tuning the Thresholds

The default settings (5 404s in 120 seconds) work well for most sites, but you should customize this for what makes sense in your application. Beyond 404s, you could also track things like:

  • 403 errors - Too many forbidden requests could indicate someone probing for access
  • Failed authentication attempts - Repeated login failures from the same IP
  • Rate limit violations - IPs that consistently hit your rate limits

Here are some other adjustments to consider:

  • High-traffic sites: Consider raising the threshold to 10+ to avoid false positives
  • First or fail endpoints: You might want to exclude certain paths from 404 tracking, especially if they are soft-deleted or frequently changing
  • Legitimate crawlers: Consider whitelisting known good bots by user agent

You could also extend the WordPress probe detection to include other common attack vectors like .env, phpinfo.php, or .git/config.

Resources


Looking to get started with Cloudflare caching? Check out my other post on Using Cloudflare Page Cache with Laravel.