Cloudflare WAF: Block the Spamhaus DROP List (Free Plan)
The Spamhaus DROP list contains netblocks controlled by professional spam and cybercrime operations. Blocking it in the Cloudflare WAF keeps that traffic away from your sites entirely — before it ever reaches your origin.
If you have tried this with WAF custom rules and given up: pasting IP ranges directly into a rule expression hits the expression size limit almost immediately, on the Free plan and on Pro alike. That is the wrong mechanism. The right one is a custom IP list — and those are available on every plan:
| Plan | IP lists | Total items |
|---|---|---|
| Free | 1 | 10,000 |
| Pro / Business | 10 | 10,000 |
The DROP list currently holds roughly 1,700 IPv4 + 100 IPv6 CIDRs (June 2026) — comfortably within the 10,000-item quota of the Free plan. The list is referenced by a single WAF rule that never changes; a daily cron job replaces the list contents through the API.
For servers not behind Cloudflare, see the companion guide: Block the Spamhaus DROP List with nftables.
Prerequisites
- A domain proxied through Cloudflare (any plan)
- Your Account ID — visible in the Cloudflare dashboard on any zone's Overview page, right sidebar
- A Linux machine with
curlandjqto run the sync (any always-on box — the server itself, a Pi, an LXC)
Free plan: one list
The Free plan includes exactly one custom list. If you already use it for something else, this approach needs Pro — or you merge both use cases into the one list.
Step 1 — Create an API Token
The sync script needs a token that can edit account-level lists — nothing more. In the dashboard: My Profile → API Tokens → Create Token → Create Custom Token with a single permission:
| Scope | Permission | Access |
|---|---|---|
| Account | Account Filter Lists | Edit |
Restrict the token to the one account, set no zone permissions. Note the token — it is shown only once.
Step 2 — Create the IP List
Once via the API (or in the dashboard under Manage Account → Configurations → Lists):
curl -s -X POST \
"https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/rules/lists" \
-H "Authorization: Bearer <API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name": "spamhaus_drop", "kind": "ip", "description": "Spamhaus DROP - synced daily"}'
The response contains the list's id — note it, the sync script needs it. List names may only contain lowercase letters, numbers, and underscores.
IP lists accept individual addresses and CIDR ranges (IPv4 /8–/32, IPv6 /12–/128), so the DROP entries can be inserted as-is.
Step 3 — The WAF Rule (Once, Never Touched Again)
In the dashboard of the zone you want to protect: Security → WAF → Custom rules → Create rule:
- Rule name:
Spamhaus DROP - Expression (use the expression editor):
(ip.src in $spamhaus_drop) - Action: Block
Repeat for each zone that should enforce the list — the list itself is account-wide, so all rules reference the same data. When the cron job updates the list, every referencing rule picks up the change automatically. This is the entire point: the rule is static, only the list contents rotate.
Step 4 — The Sync Script
Save as /usr/local/sbin/update-spamhaus-cf.sh:
#!/usr/bin/env bash
set -euo pipefail
ACCOUNT_ID="your-account-id"
LIST_ID="your-list-id"
API_TOKEN="your-api-token"
BODY=$(curl -sf https://www.spamhaus.org/drop/drop_v4.json \
https://www.spamhaus.org/drop/drop_v6.json \
| jq -s '[ .[] | select(.cidr != null) | {ip: .cidr, comment: "Spamhaus DROP"} ]')
COUNT=$(echo "$BODY" | jq 'length')
[ "$COUNT" -gt 100 ] || { echo "ERROR: only $COUNT entries parsed, aborting"; exit 1; }
RESULT=$(echo "$BODY" | curl -sf -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/rules/lists/$LIST_ID/items" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
--data @-)
echo "$RESULT" | jq -e '.success' >/dev/null \
|| { echo "ERROR: API call failed: $RESULT"; exit 1; }
echo "Spamhaus DROP synced to Cloudflare: $COUNT entries"
Fill in the three variables, then protect and test it:
How it works:
- One
curl, two URLs — both feeds are fetched in a single call and concatenated;jq -sslurps the NDJSON lines into one array. The metadata line of each feed has nocidrfield and is filtered out byselect. PUT .../itemsreplaces the entire list — no diffing needed. The old entries are removed and the new set is installed in one operation. The call is asynchronous on Cloudflare's side; for a daily blocklist sync, waiting for completion is unnecessary.- The sanity check (
-gt 100) prevents an empty or truncated download from wiping your list. chmod 700because the script contains the API token. If other admins share the box, move the token into a separate root-owned file and source it.
Download limits
Spamhaus asks consumers to fetch the feeds at most once per hour, ideally once per day — excessive polling gets your IP blocked by Spamhaus. Daily is also plenty: the list changes slowly.
Step 5 — Daily Cron
echo '43 5 * * * root /usr/local/sbin/update-spamhaus-cf.sh 2>&1 | logger -t spamhaus-cf' | sudo tee /etc/cron.d/spamhaus-cf
Output goes to syslog; an odd minute avoids hammering Spamhaus on the full hour like everyone else.
Verify
Check the item count via API:
curl -s "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/rules/lists/<LIST_ID>" \
-H "Authorization: Bearer <API_TOKEN>" | jq '.result.num_items'
In the dashboard, Security → Events shows blocked requests with the rule name Spamhaus DROP once traffic from listed ranges arrives — for most sites that takes hours, not weeks; these networks scan constantly.
What About ASN-DROP?
Spamhaus also publishes ASN-DROP (~420 autonomous systems). Cloudflare supports ASN lists, but only on Enterprise plans. On Free/Pro the only route is an inline expression (ip.src.asnum in {…}) maintained by a script — possible, but it fights the expression size limit the whole way and adds little: the DROP IP ranges already cover the worst of these networks. Recommendation: skip ASN-DROP unless you are on Enterprise.
Common Issues
| Symptom | Cause | Fix |
|---|---|---|
403 from the Cloudflare API |
Token lacks the permission or is zone-scoped | Token needs Account → Account Filter Lists → Edit |
| List creation fails with name error | Invalid characters | Lowercase letters, numbers, underscores only |
10001 / item validation error |
Malformed entry in the body | Check the jq output — every item must be {"ip": "<cidr>"} |
| Rule not blocking | Rule created in the wrong zone, or list empty | Verify num_items via API and the rule's zone |
| Sync works but events show nothing | No DROP traffic yet, or another rule blocks first | Check rule order under Custom rules; patience otherwise |