How to slow down login attacks and bots with Nginx rate limiting
Let each visitor try to log in only a few times a minute. Bots that guess passwords get "429 Too Many Requests" right away, before PHP even runs. Normal visitors don't notice a thing.
Before you start
- You need Nginx on Ubuntu or Debian and a PHP site behind it. We tested on Ubuntu 24.04 (Nginx 1.24, WordPress) and Debian 13 (Nginx 1.26, Laravel).
- Rate limiting slows guessing down. It doesn't replace long passwords and two-factor login.
- The examples use the site file
/etc/nginx/sites-available/shopand the PHP-FPM socket/run/php/php8.3-fpm.sock. Use your own names.
1. How it works, in plain words
Nginx keeps a small counter for each visitor's address. You set three things:
- rate: how often a visitor may ask.
10r/mmeans 10 requests a minute, so one every 6 seconds. - burst: how many extra requests may come at once, for example when someone mistypes a password twice. With
burst=5, five more are allowed on top of the rate. - nodelay: answer the allowed ones straight away. Without it, Nginx holds them and lets them through slowly, one every 6 seconds, and the visitor waits.
Anything over the limit is refused. By default Nginx answers 503, which looks like your server is down. We'll change it to 429 Too Many Requests.
2. Set up the counters
Make one file for the shared settings. On Ubuntu and Debian, files in /etc/nginx/conf.d/ are loaded for every site:
sudo nano /etc/nginx/conf.d/ratelimit.conf
# Count only POST requests (login tries). Opening the page is never limited.
map $request_method $login_key {
POST $binary_remote_addr;
default "";
}
limit_req_zone $login_key zone=login:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_req_status 429;
limit_conn_status 429;
What this does:
logincounts only form posts, so someone can open the login page as often as they like. Only sending a password counts.apiallows 10 requests a second per address, for busier pages such as an API.peripcounts open connections, for downloads (step 5).10mis the memory for the counters. 10 MB holds tens of thousands of addresses, which is plenty.
This file alone changes nothing yet. The limits work only where you turn them on, in the next steps.
3. Limit WordPress logins and xmlrpc.php
Add these blocks to the site's server block, next to your main PHP block. Use the same fastcgi_pass socket as that block:
location = /wp-login.php {
limit_req zone=login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location = /xmlrpc.php {
limit_req zone=login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
Both use the same counter, so a bot can't get more tries by switching from one to the other. If you don't need xmlrpc.php at all, block it instead, as in step 4 of our WordPress hardening guide.
Check and apply:
sudo nginx -t && sudo systemctl reload nginx
In our test, the first 6 wrong passwords in a row got the normal login page, and from the 7th on the answer was 429. After 6 seconds one more try was allowed. Opening the login page, the home page and logging in with the right password all kept working.
4. Limit Laravel's login and API
In Laravel, every page goes through index.php, so you limit by address and then hand the request on as usual:
location = /login {
limit_req zone=login burst=5 nodelay;
try_files $uri /index.php?$query_string;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
try_files $uri /index.php?$query_string;
}
Keep your normal location / and location ~ \.php$ blocks. If your login lives at another address, such as /admin/login, use that one. Reload Nginx as in step 3.
In our test, 40 API calls as fast as possible got about 26 answers and then 429. Calls a fifth of a second apart all went through. If your app already limits logins itself, for example with Laravel's throttle middleware, keep that too. Nginx simply stops the flood before PHP has to work.
Pick generous numbers for the API. An office or a mobile network can put many people behind one address, and they all share one counter.
5. Limit downloads at the same time
Large files can tie up your server if one address opens dozens of downloads. This lets each address download 2 files at once:
location /downloads/ {
limit_conn perip 2;
}
In our test, a third download started while two were running got 429, and the first two finished normally.
6. See what was blocked
Every refused request is in the access log with status 429:
sudo grep '" 429 ' /var/log/nginx/access.log | tail
The error log says which counter refused it and for which address:
sudo grep 'limiting requests' /var/log/nginx/error.log | tail
The lines look like this: limiting requests, excess: 5.761 by zone "login", client: 203.0.113.7.
Want fewer lines? limit_req_log_level warn; logs refusals as warnings. But on Ubuntu and Debian the error log only keeps errors, so in our test the lines then disappeared completely. Leave it out if you use Fail2ban with the log (step 9).
Not sure about your numbers? Add limit_req_dry_run on; next to a limit_req line. Nothing is refused, but the error log shows limiting requests, dry run for each request that would have been. Watch it for a day, then remove the line.
7. Behind Cloudflare or another proxy: use the visitor's real address
With a proxy in front, every request comes from the proxy's address. Then all your visitors share one counter. You need to tell Nginx to read the visitor's address from a header the proxy adds.
Only trust your proxy's own addresses. Anyone can send that header. In our test, when Nginx trusted the wrong address, a bot just changed the header on every request and never hit the limit. When the header came from an address Nginx didn't trust, Nginx ignored it, and the limit worked.
Cloudflare publishes its address ranges. This writes them into a file, with the header Cloudflare uses:
{
for ip in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
echo "set_real_ip_from $ip;"
done
echo "real_ip_header CF-Connecting-IP;"
} | sudo tee /etc/nginx/conf.d/cloudflare-realip.conf
sudo nginx -t && sudo systemctl reload nginx
Check that the file lists about 20 set_real_ip_from lines. Cloudflare changes its ranges now and then, so run it again every few months.
This only helps if visitors can't skip Cloudflare and reach your server directly. Our guide on locking your server to Cloudflare shows how.
Your own proxy or load balancer (replace 10.0.0.5 with its address):
set_real_ip_from 10.0.0.5;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
With real_ip_recursive on, Nginx skips the addresses you trust and takes the last one before them. In our test, a fake address that the visitor put first in the header was ignored.
After this, the logs show the visitor's address, each visitor gets their own counter, and PHP sees the real address too.
8. Test it
From your own computer, send 10 login tries in a row. Replace the address with your site:
for i in $(seq 1 10); do
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://shop.example/wp-login.php
done
You should see a few 200 lines, then 429. For Laravel, use /login: Laravel answers 419 at first, because the test sends no form token. That's fine, what matters is the 429 after it.
Then check that normal browsing still works: open the home page and a few other pages in your browser. Your address is free again after a minute.
9. Add Fail2ban for repeat offenders
Rate limiting slows each bot down. Fail2ban can then block addresses that keep hitting the limit. It comes with a ready-made rule that reads the "limiting requests" lines from step 6. Set Fail2ban up with our guide first, then:
sudo nano /etc/fail2ban/jail.d/nginx-limit-req.local
[nginx-limit-req]
enabled = true
backend = auto
maxretry = 10
findtime = 10m
bantime = 1h
sudo fail2ban-client -t
sudo systemctl restart fail2ban
sudo fail2ban-client status nginx-limit-req
This blocks an address after 10 refused requests in 10 minutes. Behind Cloudflare, a block on your server does nothing, because the visitor's traffic arrives from Cloudflare. There, the rate limit itself is your protection.
Apache?
Apache has nothing as simple built in. mod_ratelimit only slows down download speed. It doesn't count requests. mod_evasive (package libapache2-mod-evasive) blocks an address that asks too fast, with 403. But in our tests some requests still got through, because each Apache process keeps its own count. It also needed different settings on each system: Debian 13's version does nothing without DOSEnabled true, and Ubuntu 24.04's version refuses that line. For login pages on Apache, we suggest Fail2ban, as in step 8 of our WordPress hardening guide.
If something goes wrong
- Everyone gets 429 on the login page: you're behind a proxy and Nginx sees only its address. Do step 7. Check with
tail /var/log/nginx/access.log: if every line starts with the same address, that's it. - Visitors get "503 Service Unavailable":
limit_req_status 429;is missing, so Nginx uses its default. - The browser downloads wp-login.php instead of showing the page: the new
locationblock doesn't pass the request to PHP, so Nginx sends the file itself. Copy theincludeandfastcgi_passlines from your main PHP block (step 3), or usetry_filesfor Laravel (step 4). - An app or mobile app stops working: its API limit is too low. Raise
rateorburst, or test the numbers first withlimit_req_dry_run on;. nginx -tsays "zero size shared memory zone": the name inlimit_req zone=doesn't match alimit_req_zoneline in step 2.
Official sources
- limit_req module (nginx.org)
- limit_conn module (nginx.org)
- realip module (nginx.org)
- map module (nginx.org)
- Cloudflare IP ranges and Cloudflare HTTP headers
- mod_ratelimit (Apache docs)
Next steps: set up Fail2ban if you haven't, and work through the security checklist for a new PHP server.
ServerNest is in development. One of our goals is safe defaults for each site, such as sensible limits on login pages. Join early access to try it first.