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:

  1. crowdsec — detected locally by your Security Engine analyzing your logs
  2. CAPI (community blocklist) — IPs flagged by the global CrowdSec network
  3. CAPI (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, 429 returned 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: