You enabled MFA. Your login page checks TOTP codes. Attackers can't get in. So why is your server still drowning?
Because credential stuffing doesn't need to succeed to hurt you. Ten thousand login attempts per hour still burn CPU, saturate database connections, pollute your logs, and probe your API for weaknesses. MFA protects the lock. It does nothing about the crowd of bots pounding on the door.
This post layers two defenses before the application: CrowdSec for collaborative IP reputation, and Caddy rate-limiting for dynamic throttling. Together they drop malicious traffic at the edge — before it reaches your app stack.
If you haven't hardened your authentication yet, start with Not All MFA Is Equal — it covers TOTP, passkeys, and what actually stops phishing.
The Problem MFA Doesn't Solve
MFA prevents unauthorized access. It does not prevent resource exhaustion.
Credential stuffing bots don't need valid credentials. They fire rapid-fire POST requests to /api/login, /auth/totp, /api/passwordless/start — hundreds or thousands per minute. Each request hits your application, queries the database, runs bcrypt or argon2, and returns a response. Multiply that across a botnet of 500 IPs and your auth endpoint is effectively DDoSed.
| Attack Type | What It Does | MFA Stops It? | Rate-Limit Stops It? | IP Reputation Stops It? |
|---|---|---|---|---|
| Credential stuffing | Tests leaked username/password pairs at scale | Yes (but app still processes every request) | Yes — throttles per-IP | Yes — drops known bad IPs |
| Password spraying | Tries common passwords across many accounts | Yes | Yes | Yes |
| API enumeration | Probes auth endpoints for valid usernames, error leaks | No | Yes | Yes |
| Brute-force TOTP | Guesses 6-digit codes (1M possibilities) | Yes (rate-limit makes it impractical) | Yes | Yes |
| Layer 7 DDoS | Floods auth endpoints to exhaust server resources | No | Yes | Partially |
MFA is your last line. You need lines before it.
CrowdSec: Not Your Parents' Fail2ban
Fail2ban has protected servers since 2004. It watches logs, counts failures, bans IPs via iptables. Simple. Reliable. Local.
CrowdSec takes the same philosophy and scales it to the internet age. It's open-source, written in Go, API-driven, and collaborative — every instance shares threat signals with a central API, which curates and redistributes a community blocklist back to all participants.
Here's the direct comparison:
| Feature | Fail2ban | CrowdSec |
|---|---|---|
| Language | Python | Go |
| Scope | Single server | Distributed (detect here, remediate there) |
| Threat intelligence | Local logs only | Global community blocklist |
| IPv6 | Partial | Full support |
| Default ban time | 10 minutes | 4 hours |
| Architecture | Monolithic | Agent + bouncers (decoupled) |
| Configuration | Regex filters in jail files | YAML parsers + scenarios + hub |
| Pre-blocked attacks | None (reacts after the fact) | ~92% of attacks already known to community |
| Installation | Package + manual jail config | Wizard + hub collections |
The key difference: Fail2ban sees only what happens on your server. CrowdSec sees what happens on every CrowdSec instance worldwide. An attacker brute-forcing a server in Frankfurt gets flagged, and your server in Dallas blocks that IP before it ever tries.
CrowdSec v1.7.8 (May 2026) is the current release. It includes fixes for a high-severity WAF bypass (CVE-2026-44982) and a Local API denial-of-service (CVE-2026-44981). If you're running an older version, upgrade.
The Community Blocklist
When CrowdSec detects an attack, it sends a sanitized signal to the central API: timestamp, scenario triggered, offending IP. No sensitive data. The API curates these signals, filters false positives, and redistributes a Community Blocklist to all participating instances.
The blocklist has ~5% daily IP rotation — attackers churn through infrastructure fast. CrowdSec's newer Threat Forecast Blocklist goes further, using behavioral prediction to block IPs before they attack you, claiming ~50% more preemptive coverage.
This means: on a fresh CrowdSec install, your first line of defense already knows about millions of malicious IPs.
Installing CrowdSec + Firewall Bouncer
Two components: the Security Engine (detects attacks) and a bouncer (enforces bans). Install both.
Install the Security Engine
# Debian/Ubuntu
curl -s https://install.crowdsec.net | sudo bash
sudo apt install crowdsec
# RHEL/Fedora
curl -s https://install.crowdsec.net | sudo bash
sudo dnf install crowdsec
The installer auto-detects running services (SSH, Nginx, Apache, etc.) and installs matching detection scenarios from the hub. No manual config needed for basic protection.
Verify:
sudo cscli hub list # installed collections, parsers, scenarios
sudo cscli decisions list # current bans (empty on fresh install)
sudo systemctl status crowdsec
Install a Firewall Bouncer
The bouncer enforces CrowdSec's decisions at the firewall level. For iptables/nftables:
sudo apt install crowdsec-firewall-bouncer-iptables
# or for nftables:
# sudo apt install crowdsec-firewall-bouncer-nftables
The bouncer polls the CrowdSec Local API (LAPI) and updates firewall rules automatically. When CrowdSec bans an IP, the bouncer drops it before any application traffic reaches your server.
sudo cscli bouncers list # verify bouncer is registered
Add Web Protection Collections
CrowdSec's hub has pre-built collections for common services:
# Web server / reverse proxy protection
sudo cscli collections install crowdsecurity/nginx
sudo cscli collections install crowdsecurity/http-cve
# If using Caddy
sudo cscli collections install crowdsecurity/caddy
These collections include parsers (to read your logs), scenarios (to detect attacks), and postoverflows (to enrich detections). After installing, restart CrowdSec:
sudo systemctl restart crowdsec
Enroll in the Community Blocklist
By default, CrowdSec shares signals but you need to opt into receiving the community blocklist:
sudo cscli capi register # register with Central API
sudo cscli config set --simulation=false # ensure not in simulation mode
Now your instance pulls the curated blocklist on every refresh cycle. Malicious IPs flagged by the global CrowdSec network are automatically banned on your server.
Rate-Limiting Auth Endpoints in Caddy
CrowdSec handles known bad IPs. Rate-limiting handles the unknowns — new bots, fresh proxy IPs, distributed attacks from IPs not yet in any blocklist.
Caddy doesn't ship rate-limiting natively. You need the caddy-ratelimit plugin compiled in via xcaddy. Full build instructions are in Adding HTTP Rate Limiting to Caddy — this section assumes you already have the custom image.
Targeting Auth Endpoints
The key insight: rate-limit auth paths only, not your entire site. Static assets, public APIs, and health checks should flow freely. Login, TOTP, and passwordless endpoints are where bots concentrate.
https://your-site.com {
# Tight rate limit on auth endpoints
rate_limit {
zone auth {
key {remote_host}
events 10
window 60s
}
}
# General rate limit for everything else
rate_limit {
zone general {
key {remote_host}
events 50
window 10s
}
}
reverse_proxy your-app:8000
}
For per-endpoint granularity, use Caddy matchers:
https://your-site.com {
# Auth endpoints: strict
@auth_paths {
path /api/login
path /api/totp
path /api/passwordless/*
path /auth/*
}
rate_limit @auth_paths {
zone auth_strict {
key {remote_host}
events 5
window 60s
}
}
# API endpoints: moderate
@api_paths {
path /api/*
}
rate_limit @api_paths {
zone api_moderate {
key {remote_host}
events 30
window 10s
}
}
reverse_proxy your-app:8000
}
Choosing Thresholds
| Endpoint Type | Events | Window | Rationale |
|---|---|---|---|
Login (/api/login) |
5 | 60s | 5 attempts per minute — generous for humans, brutal for bots |
TOTP verify (/api/totp) |
5 | 60s | Same — TOTP brute-force needs speed |
| Passwordless start | 3 | 60s | Magic link abuse — very tight |
| General API | 30–50 | 10s | Normal app usage doesn't burst this hard |
| Static assets | None | — | Don't rate-limit CSS/JS/images |
When a client exceeds the limit, Caddy returns 429 Too Many Requests with a Retry-After header. Legitimate users who hit the limit (unlikely with these thresholds) can simply wait and retry.
What the Client Sees
# First 5 requests: 200 OK
curl -s -o /dev/null -w '%{http_code}' https://your-site.com/api/login
# 200
# 6th request within 60 seconds: 429
curl -s -o /dev/null -w '%{http_code}' https://your-site.com/api/login
# 429
The bot gets throttled. Your app never processes the excess requests. CPU and database connections stay healthy.
IP Reputation Filtering: Dropping Traffic at the Edge
CrowdSec's bouncer operates at the firewall level. When it drops an IP, the traffic never reaches Caddy, never reaches your application, never touches a database query. This is the cheapest possible rejection — a single iptables rule versus an application-layer response.
What Gets Blocked
# View current decisions (bans)
sudo cscli decisions list
# Example output:
+--------+----------+-------------------+--------------------------------+--------+---------+----+
| SCOPE | VALUE | REASON | ORIGIN | ACTION | COUNTRY | AS |
+--------+----------+-------------------+--------------------------------+--------+---------+----+
| Ip | 1.2.3.4 | crowdsecurity/ssh-bf | CAPI (community blocklist) | ban | CN | ...|
| Ip | 5.6.7.8 | crowdsecurity/http-cve| crowdsec | ban | RU | ...|
| Range | 10.0.0/24| crowdsecurity/http-scan| CAPI (threat forecast) | ban | US | ...|
+--------+----------+-------------------+--------------------------------+--------+---------+----+
Three origins for bans:
crowdsec— detected locally by your Security Engine analyzing your logsCAPI(community blocklist) — IPs flagged by the global CrowdSec networkCAPI(threat forecast) — predicted malicious IPs based on behavioral analysis
Monitoring Detection
# Overall metrics
sudo cscli metrics
# Shows:
# - Parsed lines per source
# - Unparsed lines (need new parsers)
# - Active scenarios and their hit counts
# - Overflow counts (bans triggered)
# - Bouncer stats (decisions received, applied)
Watch for unparsed lines — they indicate log formats CrowdSec doesn't recognize yet. Install additional parsers from the hub or write custom ones.
The Layered Defense
Each layer stops a different class of attack. None is sufficient alone. Together, they form a wall.
┌─────────────────────────────────────────────────┐
│ INTERNET │
│ bots, scanners, credential stuffers │
└─────────────────────┬───────────────────────────┘
│
┌─────────────────────▼───────────────────────────┐
│ CROWDSEC FIREWALL BOUNCER │
│ Drop known bad IPs at iptables/nftables │
│ Community blocklist + Threat Forecast │
│ ~92% of attackers stopped here │
└─────────────────────┬───────────────────────────┘
│ (remaining traffic)
┌─────────────────────▼───────────────────────────┐
│ CADDY REVERSE PROXY │
│ Rate-limit auth endpoints: 5 req/min/IP │
│ 429 on excess — app never sees it │
│ Catches unknown bots, distributed attacks │
└─────────────────────┬───────────────────────────┘
│ (surviving requests)
┌─────────────────────▼───────────────────────────┐
│ YOUR APPLICATION │
│ MFA (TOTP/passkeys) blocks stolen creds │
│ CSRF protection, session hardening │
│ Only legitimate traffic reaches here │
└─────────────────────────────────────────────────┘
Layer 1 — CrowdSec (firewall): Cheapest rejection. IP already in community blocklist? Dropped at the kernel. Zero CPU cost to your application.
Layer 2 — Rate-limiting (reverse proxy): Unknown attacker, fresh IP, not in any blocklist? Gets throttled to 5 attempts per minute on auth endpoints. Brute-force becomes impractical. Caddy returns 429 — your app processes nothing.
Layer 3 — MFA (application): Last defense. Even if an attacker has valid credentials (phished, leaked), TOTP or passkeys stop them. But this layer should rarely need to work hard — layers 1 and 2 handle the bulk of the noise.
Monitoring and Avoiding False Positives
Watch CrowdSec
# Real-time decisions log
sudo cscli decisions watch
# Metrics dashboard
sudo cscli metrics
# Check what scenarios are active
sudo cscli scenarios list
If legitimate users get banned, unban quickly:
sudo cscli decisions delete --ip 203.0.113.50
Watch Caddy Rate-Limiting
Caddy logs 429 responses in its default access log:
# Count 429s in the last hour
docker logs caddy 2>&1 | grep '"status":429' | wc -l
# See which IPs are getting throttled
docker logs caddy 2>&1 | grep '"status":429' | grep -oP '"remote_ip":"<sup id="fnref-footnote-1" class="footnote-ref"><a href="#fn-footnote-1" aria-label="Footnote 1">[1]</a></sup>+"' | sort | uniq -c | sort -rn | head
A sudden spike in 429s means either an active attack (good — rate-limiting is working) or a misconfigured client (fix the client).
Exempting Trusted Sources
If you have known-good IPs that shouldn't be rate-limited (monitoring services, partner APIs), exempt them:
rate_limit {
zone auth {
key {remote_host}
events 5
window 60s
}
# Trusted monitoring IP — no limit
whitelist {
match 203.0.113.10/32
}
}
For CrowdSec, add whitelists in /etc/crowdsec/parsers/s02-enrich/whitelist.yaml.
Wrapping Up
Credential stuffing is volume, not precision. Attackers spray leaked credentials across thousands of sites hoping for a hit. MFA stops the hit. CrowdSec and rate-limiting stop the spray.
What you get:
- CrowdSec community blocklist — ~92% of attackers already flagged, dropped at the firewall before they reach your server
- Caddy rate-limiting — unknown bots throttled to a crawl on auth endpoints,
429returned at the proxy layer - MFA as last defense — TOTP or passkeys block stolen credentials that slip through
- Zero application changes — both layers operate outside your app code
- Automatic recovery — rate limits expire, CrowdSec bans have default TTLs, no manual cleanup needed
Install CrowdSec. Add rate-limiting to Caddy. Keep MFA enabled. Three layers, each catching what the others miss.
Further reading:
- Not All MFA Is Equal — choosing the right MFA method
- Adding HTTP Rate Limiting to Caddy — full build guide for the rate-limit plugin
- Beyond the Login Screen — securing sessions after authentication
- CrowdSec documentation — hub collections, custom scenarios, distributed setup