Originally published on kuryzhev.cloud
Cloudflare WAF credential stuffing attacks don't look like a DDoS, and that's exactly why our first response made things worse. At 3am, PagerDuty woke me up because /api/v1/auth/login latency had jumped from 80ms to 4 seconds and origin CPU was pinned at 100% on a 4-vCPU box sitting behind Cloudflare. By the time I had coffee in hand, support tickets were piling up from real users locked out of their own accounts.
The problem we hit
The Cloudflare Analytics dashboard told the real story fast: 340k requests per minute hitting /auth/login, coming from roughly 9,000 unique IPs, almost all in residential proxy ranges. Every request had a valid-looking JSON body and a rotating User-Agent header. Nothing screamed "bot" at a glance — no malformed payloads, no obvious SQLi patterns, just a flood of plausible login attempts.
Our first instinct was to trust the app's existing rate limiter, a Redis-backed counter keyed per-endpoint. It did exactly what it was built to do: it started blocking after N failed attempts on the login path. Problem was, it counted by endpoint, not by identity or session, so once the attack volume crossed the threshold, it started rejecting legitimate mobile users too — anyone behind shared NAT or CGNAT got caught in the crossfire. We had turned a targeted attack into a self-inflicted outage for real customers, which is arguably worse.
We briefly flipped on Cloudflare's "I'm Under Attack" mode, hoping for a quick win. It didn't touch the traffic — because this wasn't a volumetric flood, it was slow, distributed, and looked like normal HTTP traffic to anything watching for spikes in raw request rate per IP.
Why it happens
Credential stuffing replays leaked username/password pairs against a login form at scale, using botnets or proxy pools to spread requests across thousands of source IPs. It's not trying to overwhelm bandwidth — it's trying to blend in. That single fact defeats most default protections out of the box.
Cloudflare's managed WAF rulesets are tuned to catch known exploit signatures: SQL injection, XSS, path traversal. A well-formed login POST with a fake username and password doesn't trip any of those signatures because, structurally, it's a completely valid request. Bot Fight Mode helps a little on lower plans, but without Bot Management scoring, you're relying on heuristics that rotating-proxy botnets are specifically built to evade.
App-level rate limiting breaks down for a more mechanical reason: Cloudflare terminates TLS at the edge, so your origin only sees Cloudflare's IPs unless you're correctly trusting CF-Connecting-IP or X-Forwarded-For. Even when that's configured right, keying purely on ip.src is a losing game — if the attacker has 9,000 IPs and your threshold is 10 requests per IP, they just spread the load. One IP per handful of requests defeats any per-IP counter, no matter how tight you set the window.
The fix (with code)
The fix needed two layers: a WAF custom rule to challenge suspicious traffic based on behavior, not just IP reputation, and a rate limiting rule keyed on a composite identifier that rotating IPs can't easily fake. We used cf.bot_management.score (available with Bot Management on Business/Enterprise, heuristic fallback otherwise) combined with cf.unique_visitor_id as a secondary rate-limit key — this ties the count to a Cloudflare-derived visitor fingerprint, not just the source IP.
We deployed this with Terraform using the cloudflare/cloudflare provider v4.x. Note: v4 rewrote rule resources into the unified cloudflare_ruleset — mixing v3's deprecated cloudflare_rate_limit or cloudflare_filter with v4 syntax is a fast way to get confusing plan diffs. Don't mix them in the same repo.
# terraform/cloudflare_waf_credential_stuffing.tf
# Provider: cloudflare/cloudflare v4.20+
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.20"
}
}
}
variable "zone_id" {
description = "Cloudflare Zone ID for the protected domain"
type = string
}
# Layer 1: WAF custom rule - challenge low bot-score traffic on login endpoint
resource "cloudflare_ruleset" "login_bot_challenge" {
zone_id = var.zone_id
name = "credential-stuffing-bot-challenge"
description = "Managed challenge for low bot-score requests hitting login"
kind = "zone"
phase = "http_request_firewall_custom"
rules {
action = "managed_challenge"
expression = "(http.request.uri.path eq \"/api/v1/auth/login\" and http.request.method eq \"POST\" and cf.bot_management.score lt 30)"
description = "Challenge suspected bots on login"
enabled = true
}
}
# Layer 2: Rate limiting rule - keyed on IP + unique visitor ID, not IP alone
resource "cloudflare_ruleset" "login_rate_limit" {
zone_id = var.zone_id
name = "credential-stuffing-rate-limit"
description = "Rate limit login attempts per composite key"
kind = "zone"
phase = "http_ratelimit"
rules {
action = "managed_challenge"
expression = "(http.request.uri.path eq \"/api/v1/auth/login\" and http.request.method eq \"POST\")"
description = "Throttle login POSTs beyond threshold"
enabled = true
ratelimit {
characteristics = ["ip.src", "cf.unique_visitor_id"]
period = 60
requests_per_period = 5
mitigation_timeout = 600
}
}
}
We deliberately set the action to managed_challenge, not block. Hard-blocking on IP reputation alone caused roughly 2% false-positive lockouts of real mobile users behind CGNAT during the incident — that's a "watch out for" I'd pin to a whiteboard. A challenge gives real humans a way through; a hard block just moves your outage from "attackers win" to "you locked out paying customers."
Before touching production, we validated the expression via the API directly, then simulated distinct source IPs against staging using CF-Connecting-IP headers to confirm the composite key actually throttled correctly:
# Manual verification via API before Terraform apply — dry-run the expression
# Requires token scope: Zone.Firewall Services:Edit
ZONE_ID="your_zone_id_here"
CF_API_TOKEN="your_scoped_token_here"
# 1. Create the ruleset via API (equivalent to the Terraform resource above)
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/phases/http_ratelimit/entrypoint" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"rules": [{
"action": "managed_challenge",
"expression": "(http.request.uri.path eq \"/api/v1/auth/login\" and http.request.method eq \"POST\")",
"ratelimit": {
"characteristics": ["ip.src", "cf.unique_visitor_id"],
"period": 60,
"requests_per_period": 5,
"mitigation_timeout": 600
},
"description": "Throttle login POSTs beyond threshold",
"enabled": true
}]
}'
# 2. Simulate distinct source IPs to confirm keying works (staging only)
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "attempt $i -> %{http_code}\n" \
-X POST "https://staging.example.com/api/v1/auth/login" \
-H "CF-Connecting-IP: 203.0.113.$i" \
-d '{"username":"test","password":"wrong"}'
done
# Expected: first 5 return 401/200, 6th onward returns 429 or challenge page (HTTP 403/503 w/ CF-Mitigated header)
# 3. Confirm which rule fired via Logpush FirewallEvents dataset (async, ~1-5min delay)
One error we hit while iterating on the expression: "1101: rule execution failed". That turned out to be a typo referencing cf.bot_management.score as cf.bot_managment.score — the dashboard's "Test Rule" preview catches this before you deploy, use it. Also worth knowing: rule quotas differ by plan — Free allows 5 custom WAF rules per zone, Pro 20, Business 100. Hit the ceiling and the API silently fails creation with HTTP 400: "reached maximum number of rules", which is a nasty surprise mid-incident when you're trying to add one more rule fast.
We also learned that phase order matters. WAF custom rules in http_request_firewall_custom execute before rate limiting rules in http_ratelimit by default. If your rate limit rule seems to be silently skipped, check whether an earlier phase rule — including an old "Skip" allowlist rule for CI/CD or a partner integration — is exempting the traffic before it ever reaches the rate limiter. We had exactly this: a legacy skip rule scoped to /api/* for a partner webhook was inadvertently exempting attacker traffic hitting /api/v1/auth/login. Audit your skip rules before layering in new blocking logic; the details are covered well in the Cloudflare WAF documentation and the Rulesets API reference.
Prevention checklist
Once the fire was out, we built guardrails so this incident class doesn't come back disguised as something else.
-
Enable Bot Fight Mode or Bot Management, and watch the score distribution. Pull
cf.bot_management.scoreweekly via Logpush so you notice drift before an attack, not during one. - Protect the pivot endpoints too. Once login is hardened, attackers move to password-reset and registration. We added the same challenge + rate-limit pattern to both within a week of the original fix.
-
Alert on challenge counts, not just blocks. Query the Security Events GraphQL Analytics API for spikes in
action = "challenge"— a rising challenge rate is your early warning before volume escalates to something that needs a 3am page. -
Scope and rotate WAF automation tokens. Use least-privilege tokens (
Zone:Firewall Services:Editonly) for Terraform CI, and keep state remote — plaintext rule IDs and zone tokens in git history are a liability you don't want to discover during an audit. -
Never leave a rate limit rule set to
logonly in production. Logging-only gives attackers a free window to keep hammering while you "observe." Pair it with at leastmanaged_challengefrom day one on any auth-adjacent endpoint.
Cloudflare WAF credential stuffing defenses only work if you treat them as a layered, monitored system rather than a one-time rule you set and forget. We revisit thresholds monthly using 30-day Logpush data pulled into R2, since dashboard analytics only retain granular detail for 24 hours on lower plans — not enough to spot slow-building attacks. If you're setting up your infra repo structure for this kind of Terraform-managed security config, our DevOps_DayS archive has more patterns worth stealing.
Top comments (0)