yum upgrades for production use, this is the repository for you.
Active subscription is required.
Technical Briefing: NGINX Abuse Guard — Banning Scanners by Response Status, Inside the Worker
The Problem
Public-facing NGINX servers are continuously probed. Scanners request paths like /wp-login.php, /.env, and /phpmyadmin, producing walls of 404s. Bots hitting locked-down admin endpoints generate 403s. Credential-stuffing runs produce failed logins. The signal is asymmetry: legitimate visitors almost never burst errors, while abusive clients do.
Existing tools address this imperfectly:
limit_reqshapes traffic by volume. During a flood it slows everyone, and it lets the offender back in as soon as the request rate eases — it does not distinguish an error-producing client from a busy one.fail2banreads error patterns correctly, but lives outside NGINX. It tails a log, parses on a delay, and shells out to the firewall, so bans land seconds to minutes after the damage.
The Fix
The NGINX abuse guard module scores the response status codes NGINX already returns, keeps a small per-client score in shared memory, and issues a timed ban enforced at the NGINX preaccess phase — before any handler, file lookup, or upstream runs. No sidecar, no log shipper, no scripting layer; the decision runs in compiled C in microseconds.
How It Works
Three moving parts, all inside the worker:
- Per-client score — each matching error response adds to a score that continuously bleeds away at
threshold ÷ intervalper second. A sharp burst trips a ban; a slow trickle of occasional 404s spread over hours never accumulates. The score is one fixed-size record regardless of threshold, so a single modest zone tracks tens of thousands of distinct source addresses. - Two lifecycle hooks — a header filter inspects the final response status and updates the offending client’s score; the preaccess phase checks whether the client is already banned. Banned clients are turned away at the cheapest possible point.
- Ban response — banned clients get
429 Too Many Requestsby default (the code is configurable), with aRetry-Afterheader andCache-Control: private, no-storeso no shared cache or CDN can store one client’s ban page and serve it to another. Identities are folded into a fixed-size digest before storage, so keying on something large like$request_urior a header costs the same memory as keying on a plain IP.
Installation
Ships as a precompiled, signed dynamic module from the GetPageSpeed repository — no build toolchain required.
RPM:
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-abuse-guard
Load it by adding this at the top of /etc/nginx/nginx.conf, in the main context before any http {} block:
load_module modules/ngx_http_abuse_guard_module.so;
APT: set up the GetPageSpeed APT repository, then:
sudo apt-get update
sudo apt-get install nginx-module-abuse-guard
On Debian/Ubuntu the package handles module loading automatically — no load_module directive needed.
Validate with sudo nginx -t before reloading on either platform.
Minimal Working Policy
Two directives: declare one shared-memory zone in http, then switch enforcement on where abuse arrives.
load_module modules/ngx_http_abuse_guard_module.so;
http {
abuse_guard_zone zone=clients:10m;
server {
location / {
abuse_guard zone=clients;
}
}
}
Then sudo nginx -t && sudo systemctl reload nginx.
Defaults: ban any single IP returning 100 403/404 responses inside a 5-minute window; the ban holds for one hour.
Directives
Four total, verified against module source.
abuse_guard_zone (context: http)
Declares a shared-memory zone and its policy. Only zone name and size are required; defaults fill the rest. Full form:
abuse_guard_zone zone=clients:10m key=$binary_remote_addr statuses=403,404 interval=300s threshold=100 block=60m;
5xxis excluded by default because a server error is usually your side’s doing, and counting it would let one flaky backend get innocent visitors banned. Addstatuses=403,404,500-599only when you deliberately want to act on clients that trigger server errors.- Weights: append
:weightto any code or range (e.g.statuses=404,403:5,500-599:2) so a 403 against a protected path costs more than a stale-link 404. A bare code or range keeps weight 1, so existing configs behave exactly as before. Weights are integers 1–1024; a range applies its weight to every status it covers; conflicting weights for the same status are rejected bynginx -trather than silently resolved.
abuse_guard (contexts: http, server, location)
Applies a declared policy. Name the zone to switch enforcement on; abuse_guard off; in a nested scope switches it back off.
abuse_guard zone=clients status=429 log_level=warn;
abuse_guard_allow (contexts: http, server, location)
Repeatable and inherited downward; listed clients are never counted and never banned. Matching is on the true connection address, so it cooperates with realip.
abuse_guard_allow 127.0.0.0/8;
abuse_guard_allow 10.0.0.0/8 192.168.0.0/16;
abuse_guard_redis (context: http)
Points the server at one Redis or Valkey instance for fleet-wide ban replication; pair with redis=on on the zone.
abuse_guard_redis host=10.0.0.5 password=secret;
abuse_guard_zone zone=clients:10m redis=on;
tls://host for TLS.
Variables
Exactly three: $abuse_guard_status, $abuse_guard_count, and one more for logging/mapping. A custom log format makes decisions visible while tuning:
log_format guard '$remote_addr "$request" $status '
'guard=$abuse_guard_status count=$abuse_guard_count';
access_log /var/log/nginx/access.log guard;
Use Cases
- Automated scanner — enforce a site-wide default policy (
statuses=404 threshold=40 interval=60s block=30m). A scanner bans itself within seconds, while a real visitor who fat-fingers one URL never comes close. - Credential stuffing / admin probing — scope a stricter policy to targeted endpoints so a handful of failures is tolerated but a sustained run is locked out, without affecting the rest of the site:
location = /wp-login.php { abuse_guard zone=clients status=429 log_level=warn; ... } - Anonymous vs. logged-in users — a request whose
keyresolves to an empty string is skipped, so amapcan track anonymous visitors by IP while leaving logged-in users untouched:map $cookie_sessionid $abuse_key { "" $binary_remote_addr; default ""; } http { abuse_guard_zone zone=clients:10m key=$abuse_key; } - Search-engine crawlers — allowlist published crawler ranges (e.g.
abuse_guard_allow 66.249.64.0/19;for Googlebot,abuse_guard_allow 157.55.0.0/16;for Bingbot) so they’re never counted; matching on the real connection address means this cooperates withrealipbehind a CDN.
Safe Rollout
Never flip a new ban policy straight to enforcing on production. Run dry_run=on first — it records every ban it would issue in the log without writing any state. You can run a dry-run location next to an enforcing one on the same zone, calibrate the threshold against real traffic, then remove dry_run=on once the numbers look right.
Fleet-Wide Replication
Behind a load balancer, a per-server ban is theatre — the attacker just lands on a different node. Point every node at one Redis/Valkey instance and set redis=on on the zone.
Each node still decides and counts locally; the instant a node issues a ban it broadcasts that one fact and records a durable copy, and every other node imports it within milliseconds (offline nodes reconcile on reconnect). Enforcement is always served from each node’s own in-memory state, so a visitor’s request never waits on a network round-trip — Redis is a one-way alarm bell, not a shared ledger consulted per request, so a slow or missing Redis can never add latency. Run it on a private network and treat write access as privileged: anything that can write to it can issue bans.
SELinux caveat: on enforcing systems (RHEL, Rocky Linux, AlmaLinux) the kernel blocks NGINX from opening the Redis connection until you run this once:
setsebool -P httpd_can_network_connect 1
Skip this and replication silently does nothing while local enforcement carries on as normal.
Persistence
Point a zone at a file and active bans are snapshotted on an interval and restored at startup, so a reload or reboot doesn’t hand every attacker a fresh slate:
abuse_guard_zone zone=clients:10m
persist=/var/lib/nginx/abuse_guard/clients.state
persist_secret=00112233445566778899aabbccddeeff;
The snapshot is written on clean shutdown and integrity-checked on load, so a truncated or corrupted file is discarded rather than trusted. With persist_secret set it’s also signed with HMAC-SHA256, so a tampered file is rejected. Keep the directory readable only by the NGINX worker user.
Positioning vs. Existing Tools
Use a rate limiter (limit_req) to shape load and protect capacity; use fail2ban for host-wide bans spanning SSH, mail, and other services; use Abuse Guard for the job neither does well — reading the errors NGINX already returns and evicting the clients defined by them, in the same worker, on the request itself. The three layer cleanly; a common production stack runs limit_req for burst control and Abuse Guard for eviction in the same location.
Behind a CDN / Reverse Proxy
Never trust a raw X-Forwarded-For — let realip resolve the true client first:
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Config Drift Caveat
A tuned policy only stays correct until someone touches the config — NGINX upgrades, module updates, and routine edits quietly reintroduce the gaps you closed, and a ban rule that silently stops matching is worse than none. GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics; it’s drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).
Companion Modules
Pair with the NGINX delay module to tarpit slow probers, the NGINX WAF module for payload inspection, or ModSecurity for NGINX for full rule-based filtering.
Availability
Pre-built package from the GetPageSpeed RPM and APT repositories; contact for licensing, volume deployments, or setup help.
Read the full article: NGINX Abuse Guard Module: Auto-Ban Scanners and Bots
