Stop brute-force login attacks with rate limits, lockout, and detection
For developers responding to password spraying, brute force, or credential stuffing against a login endpoint. This runbook shows how to confirm the attack pattern, identify where controls are missing or mis-keyed, and apply concrete rate limiting, lockout, and detection fixes at the edge and in the app.
TL;DR — If your login endpoint is getting hammered, the fastest reliable fix is to enforce rate limiting on the real client IP at the edge and add app-side throttling keyed by account + IP/subnet, with short lockouts and clear logging. Most broken setups either trust the wrong source IP, only rate-limit per IP so credential stuffing still works, or accidentally exempt
/loginfrom the proxy/WAF path. Reading time: ~6 min
The scenario
It is 14:20 on a Tuesday and your auth logs suddenly jump from a few failed logins per minute to thousands. Support is forwarding screenshots from real users saying "my account locked after one bad password," while your metrics show successful logins are still happening from the same endpoint. You added a reverse proxy months ago, moved TLS termination to the edge last sprint, and now you are not sure whether the app is seeing the real client IP or the load balancer's address. Meanwhile, the attacker is rotating usernames and IPs fast enough that your old per-IP limit is barely slowing them down.
Symptoms
POST /loginor/sessionsvolume spikes sharply, usually with401,403, or429responses.- Access logs show repeated failures against many usernames from one IP, or one username from many IPs.
- App logs contain repeated auth failures such as:
WARN auth failed user=jane@example.com ip=203.0.113.42 reason=bad_password
WARN auth failed user=admin@example.com ip=198.51.100.77 reason=user_not_found
INFO login throttled key=ip:203.0.113.42 retry_after=60
INFO account locked user=jane@example.com until=2026-10-30T14:32:10Z
- Users see messages like
Too many login attempts. Try again later.orAccount temporarily locked. - If the proxy is misconfigured, logs show the same source for every request, for example
10.0.2.15or127.0.0.1. curl -Iagainst the login route may show no rate-limit headers at all, or a redirect that bypasses the protected path:
curl -k -I https://app.example.com/login
HTTP/2 302
location: /auth/login
server: nginx
- Nginx may log limiting events like:
2026/10/30 14:21:09 [error] 4123#4123: *881 limiting requests, excess: 20.400 by zone "login", client: 203.0.113.42, server: app.example.com, request: "POST /login HTTP/2.0", host: "app.example.com"
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Rate limiting is keyed on the wrong client IP because proxy headers are not trusted correctly | Very common | `awk '{print $1}' /var/log/nginx/access.log |
| Only per-IP throttling exists, so credential stuffing across many IPs or accounts bypasses it | Very common | `grep 'POST /login' /var/log/nginx/access.log |
/login is bypassing the edge/proxy/WAF due to routing or redirect mismatch | Common | curl -k -I https://app.example.com/login |
| Lockout policy is too aggressive or global, causing user-impact without slowing attackers | Common | `grep -E 'account locked |
| Detection/alerting is missing, so the attack is only noticed after user complaints | Common | `grep 'POST /login' /var/log/nginx/access.log |
| Login endpoint is not normalized by username/account key, so attackers evade by casing/alias variations | Less common | `grep 'auth failed user=' app.log |
Step-by-step diagnosis
- Check whether the app/proxy sees the real client IP.
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
If the top source is your load balancer, reverse proxy, or 127.0.0.1, this is your problem. Jump to Fixes → Wrong client IP due to proxy header trust.
- Confirm whether the protected route is actually the one being hit.
curl -k -I https://app.example.com/login
If you get a redirect, follow it and inspect the final route:
curl -k -IL https://app.example.com/login
Output like this means your limiter may be attached to the wrong path:
HTTP/2 302
location: /auth/login
HTTP/2 200
content-type: text/html
If the real POST target is /auth/login or /api/session and only /login is limited, jump to Fixes → Login route bypasses the protected path.
- Check whether you are only throttling per IP.
grep 'POST /login' /var/log/nginx/access.log | tail -200
If you see one IP trying many usernames, or many IPs trying one username, and there are few/no 429 responses, this is your problem. Jump to Fixes → Only per-IP throttling is configured.
- Inspect lockout behavior in the app.
grep -E 'account locked|throttled|auth failed' app.log | tail -100
If one typo from a legitimate user immediately causes lockout, or lockouts are long and global per account, this is your problem. Jump to Fixes → Lockout policy is too aggressive or global.
- Measure whether detection is delayed or absent.
grep 'POST /login' /var/log/nginx/access.log | awk '$9 ~ /401|403|429/' | wc -l
If the count is high but you have no alert, dashboard, or log query for it, jump to Fixes → Detection and alerting are missing.
- Check username normalization.
grep 'auth failed user=' app.log | tail -100
If you see variants like Jane@Example.com, jane@example.com, and jane+promo@example.com treated as different keys when your identity model says they should map to one account, jump to Fixes → Username/account key is not normalized.
View as text
[Attacker Botnet] ---- 1. POST /login ----> [Edge Proxy]
| |
| 5. rotate IPs | 2. trust XFF?
v v
[Many Source IPs] ---- 3. forward ----> [App Auth]
|
| 4. key by IP? user? account?
v
[Rate Limit]Fixes
Wrong client IP due to proxy header trust
If nginx is behind a load balancer or CDN, configure trusted proxy ranges and use the forwarded address as the real client IP.
http {
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
log_format main '$remote_addr - $host "$request" $status';
access_log /var/log/nginx/access.log main;
}
Reload nginx:
nginx -t && sudo systemctl reload nginx
If your app also reads proxy headers directly, pin trusted proxies there too; do not trust arbitrary X-Forwarded-For from the public internet.
Verify it worked:
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
You should now see varied public client IPs, not only the balancer address.
Only per-IP throttling is configured
Use two keys: one at the edge per IP/subnet to absorb floods, and one in the app per account identifier to stop distributed stuffing.
Nginx edge limit:
http {
limit_req_zone $binary_remote_addr zone=login_ip:10m rate=5r/m;
server {
location = /auth/login {
limit_req zone=login_ip burst=10 nodelay;
proxy_pass http://app;
}
}
}
App-side pseudo-config logic:
{
"login_throttle": {
"per_account_window_seconds": 900,
"per_account_max_failures": 5,
"per_ip_window_seconds": 60,
"per_ip_max_failures": 20,
"lockout_seconds": 900
}
}
If you store counters in Redis, key them like login:acct:<normalized-id> and login:ip:<ip-or-/24> with INCR + EXPIRE.
Trade-off: per-account lockout stops stuffing but can be abused for denial-of-service against a known user. Keep lockouts short and combine with MFA/challenge after threshold rather than indefinite hard lock.
Verify it worked:
for i in $(seq 1 12); do curl -sk -o /dev/null -w '%{http_code}
' -X POST https://app.example.com/auth/login -d 'username=test@example.com&password=bad'; done
You should start seeing 429 after the threshold.
Login route bypasses the protected path
Attach limits to the actual POST target, not just the page that renders the form. Follow redirects and protect API routes used by SPA/mobile clients too.
curl -k -IL https://app.example.com/login
Then update nginx:
location = /login {
proxy_pass http://app;
}
location = /auth/login {
limit_req zone=login_ip burst=10 nodelay;
proxy_pass http://app;
}
location = /api/session {
limit_req zone=login_ip burst=10 nodelay;
proxy_pass http://app;
}
If your edge provider has path-based rules, add the real login POST path in your provider's dashboard under the rules/firewall/rate-limiting section for the hostname serving auth.
Verify it worked:
curl -k -I https://app.example.com/auth/login
Then send repeated POSTs and confirm 429 appears on the final route.
Lockout policy is too aggressive or global
Replace long hard lockouts with short temporary lockouts or progressive backoff, and scope counters carefully.
Example policy:
{
"per_account_max_failures": 5,
"lockout_seconds": 300,
"backoff_seconds": [0, 2, 5, 15, 60],
"reset_on_success": true
}
If you currently lock the whole account globally after 3 failures from any source, change it to: short account lockout plus IP/subnet throttling plus alert. Keep user-facing messages generic; do not reveal whether the username exists.
⚠️ Changing lockout logic can affect active users immediately. Roll it out during a low-traffic window if your auth service is shared across tenants.
Verify it worked:
grep -E 'account locked|throttled' app.log | tail -20
You should see short lockouts/backoff events rather than long account-wide locks.
Detection and alerting are missing
Add a basic detector on failed-login rate, unique usernames per source IP, and lockout volume.
Example shell query for a cron-based detector:
grep 'POST /auth/login' /var/log/nginx/access.log | awk '$9 ~ /401|403|429/ {print $1}' | sort | uniq -c | sort -nr | head
Example app log query shape to alert on many distinct usernames from one IP:
grep 'auth failed' app.log | awk -F'ip=| user=' '{print $3, $2}' | awk '{print $1, $2}' | sort | uniq | awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' | sort -nr | head
Alert when any source IP attempts more than a sane threshold of distinct usernames in 5 minutes, or when 429 rate jumps above baseline.
Verify it worked:
crontab -l
And trigger a test alert from staging or your log pipeline.
Username/account key is not normalized
Normalize the identifier before rate-limit lookup and auth attempt logging.
Example normalization rules:
{
"username_normalization": {
"trim_whitespace": true,
"lowercase_email_domain": true,
"lowercase_full_email": true
}
}
If your product treats email aliases as the same account, normalize them consistently before generating throttle keys. If it does not, do not collapse aliases at the throttle layer or you may create false lockouts.
Verify it worked:
grep 'auth failed user=' app.log | tail -20
The same account should now log under one normalized key.
Prevention
- Add explicit rate-limit tests in CI against the real login POST route.
for i in $(seq 1 8); do code=$(curl -sk -o /dev/null -w '%{http_code}' -X POST https://staging.example.com/auth/login -d 'username=ci@example.com&password=bad'); echo "$i $code"; done
Fail CI if no 429 appears after the expected threshold.
- Log the fields you need for detection: normalized username/account key, source IP, forwarded IP chain, outcome, and lockout reason.
{"event":"auth_failed","user_key":"jane@example.com","ip":"203.0.113.42","xff":"203.0.113.42, 10.0.1.12","reason":"bad_password"}
Without these fields, you cannot distinguish stuffing from normal user mistakes.
- Pin trusted proxy ranges in config management and test them after infra changes.
nginx -T | grep -E 'set_real_ip_from|real_ip_header|real_ip_recursive'
Run this in post-deploy checks whenever you change load balancers, CDNs, or ingress.
- Track three auth metrics: failed logins by status,
429count on auth routes, and distinct usernames per source IP over 5 minutes.
grep 'POST /auth/login' /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c
A drop in 429 during an attack usually means the limiter is bypassed or broken.
-
Add MFA or step-up challenge after repeated failures on an account, not just after successful password entry from a new device. This reduces the value of stuffed credentials that are actually valid.
-
Keep lockouts short and reset counters on successful login.
{"lockout_seconds":300,"reset_on_success":true}
This limits user pain while still slowing automated guessing.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI