Network-Level Blocking
This page is the complete recipe for dropping known attackers at the network layer of a Linux server: one block set per exposed service, refreshed hourly from the API, swapped into the kernel atomically, and applied port-scoped so a web attacker never locks anyone out of SSH. It covers ipset with iptables, native nftables, IPv6, and cloud edge firewalls.
Everything below assumes an API key with threat-feed access (Contributor tier and up) and root on
the target machine. The /blacklist endpoint is feature-gated but does not count against
your daily check or report quota.
How it works
Fetch one list per service, hourly
The category filter turns the blacklist into a per-service list: SSH attackers,
mail attackers, web attackers, and so on. Lists regenerate server-side every 15 minutes;
hourly polling with If-None-Match keeps you current at 120 requests per day.
Swap it into a kernel set atomically
The IPs go into an ipset or nftables set, never into individual firewall rules. One set lookup is a hash lookup regardless of size; 10,000 individual rules would be traversed linearly for every packet. The swap is atomic, so there is no window in which the set is empty or half-populated.
Match the set port-scoped
One rule per service references its own set and only the ports that service listens on. An IP reported for WordPress login brute force is dropped on 80/443 and nowhere else.
Service lists and their categories
Reports carry threat-category IDs. category accepts a comma-separated list and returns
only IPs reported under at least one of them. The full catalogue (58 categories, IDs 1–58) is
on the Threat Categories page or via
GET /categories without authentication.
| Set | category |
Covers | Apply to |
|---|---|---|---|
ssh |
22,18 |
SSH Brute-Force (22), generic Brute-Force (18) | sshd — port 22 |
mail |
11,7,17,18 |
Email Spam (11), Phishing (7), Spoofing (17), Brute-Force (18) for SMTP-AUTH/IMAP/POP3 | Postfix/Exim, Dovecot — ports 25, 465, 587, 110, 143, 993, 995 |
web |
see below | Web App Attack (21), SQL Injection (16), Web Spam (10), Bad Web Bot (19), Blog Spam (12) plus the dedicated WordPress block (31–58): login/XML-RPC/REST brute force, plugin, theme and core exploits, comment and registration spam, backdoors, scanning | nginx/Apache incl. WordPress hosting — ports 80, 443 |
ftp |
5,18 |
FTP Brute-Force (5), generic Brute-Force (18) | vsftpd/proftpd — port 21 |
edge |
4,14,20 |
DDoS Attack (4), Port Scan (14), Exploited Host (20) | entire network edge, all ports |
Full category value for the web set:
21,16,10,19,12,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58
Category 18 (generic Brute-Force) appears in the ssh, mail and
ftp sets on purpose: it collects credential attacks that the reporter did not attribute
to a protocol, and the sets are applied per service port anyway. If you would rather run a single
block set for everything, omit category — the unfiltered list is the superset of
all five.
The request
Authenticate with the X-Key header rather than the key query parameter, so
the key does not end up in proxy or CDN logs.
curl -sS -H "X-Key: YOUR_API_KEY" \
"https://reportedip.com/wp-json/reportedip/v2/blacklist?confidence=90&format=txt&limit=50000&category=22,18"
| Parameter | Value | Why |
|---|---|---|
confidence |
90 |
The tier recommended for unattended blocking. The 50/75/90 tiers are pre-generated server-side every 15 minutes, so they answer fast and complete. Non-standard values (91, 93, 97) are built per request and are slower |
format |
txt |
One IP per line, no wrapper — pipes straight into ipset restore or
nft -f |
limit |
10000 or higher |
Response cap. Omitting it returns 10,000 entries; the tiers are cached at up to
50,000, so limit=50000 returns everything the tier holds (see below) |
category |
per service | Restricts the list to matching attack types (table above). Omit for one combined set |
How large the lists get
Every tier is regenerated server-side every 15 minutes and cached at up to 50,000 entries, which is
also the hard ceiling per response. The default response size stays at 10,000 for backwards
compatibility — ask for more explicitly with limit=50000.
confidence | Typical size | Use for |
|---|---|---|
90 |
around 10,000 | Unattended blocking. Fits in one request and inside the item limits of most cloud firewalls |
75 |
around 26,000 | Wider coverage where a false positive is cheap to recover from — rate limiting, CAPTCHA, scoring, or blocking on a service that is not customer-facing |
50 |
50,000 (ceiling) | Analysis, SIEM enrichment, and scoring. Too broad for unattended DROP rules |
Category filters narrow whichever tier you request, they do not reach past it — a
web list built from confidence=75 is larger than the same list built from
confidence=90. Beyond 50,000 addresses, list downloads stop being the right tool: query
the DNS/RBL zone or
GET /check per address instead, which covers the whole database with no size limit at
all.
Why confidence 90 keeps false positives low
The score is not a report counter. An IP needs at least 10 effective reports to pass 74 % at all
and at least 2 independent reporters, reports decay with a 30-day half-life so an IP that stopped
attacking drops off by itself, and whitelisted infrastructure (search engines, CDNs, monitoring,
research scanners) is excluded before the list is built. Scores of 90 and above in practice require
many recent reports from independent sources, usually corroborated by honeypot sensors. You can audit
any entry with GET /check?ip=<ip>&verbose=true, which returns the full score
breakdown.
Operational rules
- Poll hourly, staggered. Five lists once an hour is 120 requests per day. Do not fire all five in the same second.
- Send the ETag back. Store the
ETagresponse header per list and return it asIf-None-Match; an unchanged list answers304 Not Modifiedwith an empty body. The tag covers the exact variant you requested, including the category filter, so keep one tag file per list. - Never flush on failure. On any status other than 200 or 304, keep the previous set active and retry next hour. A firewall that empties itself because a fetch timed out is worse than a slightly stale one.
- Sanity-check the size. Refuse to apply a list that shrank drastically. The script below rejects anything below a per-list minimum, which catches truncated and empty responses.
- Split IPv4 and IPv6. The feed contains both. An ipset created for one family rejects addresses of the other, so each list needs two sets.
Sync script (ipset)
One script serves all five lists; the list name is the only argument. It fetches conditionally,
validates the size, and swaps the result into the live set atomically. Entries are loaded through
ipset restore in a single pass rather than one ipset add per IP, which
keeps a 10,000-entry update well under a second.
#!/bin/sh
# /usr/local/sbin/reportedip-sync.sh <ssh|mail|web|ftp|edge>
# Fetches one service list and swaps it into its ipset atomically.
set -u
API_KEY="YOUR_API_KEY"
BASE="https://reportedip.com/wp-json/reportedip/v2/blacklist?confidence=90&format=txt&limit=50000"
STATE="/var/lib/reportedip"
LIST="${1:-}"
# CATS = category filter, MIN = smallest plausible list size (refuse below this)
case "$LIST" in
ssh) CATS="22,18" ; MIN=1000 ;;
mail) CATS="11,7,17,18" ; MIN=500 ;;
web) CATS="21,16,10,19,12,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58" ; MIN=500 ;;
ftp) CATS="5,18" ; MIN=500 ;;
edge) CATS="4,14,20" ; MIN=1000 ;;
*) echo "usage: $0 ssh|mail|web|ftp|edge" >&2 ; exit 2 ;;
esac
mkdir -p "$STATE"
BODY=$(mktemp) ; HEAD=$(mktemp)
trap 'rm -f "$BODY" "$HEAD" "$BODY.v4" "$BODY.v6"' EXIT
ETAG="$STATE/etag-$LIST"
if [ -s "$ETAG" ]; then
CODE=$(curl -sS -o "$BODY" -D "$HEAD" -w '%{http_code}' --max-time 60 \
-H "X-Key: $API_KEY" -H "If-None-Match: $(cat "$ETAG")" "$BASE&category=$CATS")
else
CODE=$(curl -sS -o "$BODY" -D "$HEAD" -w '%{http_code}' --max-time 60 \
-H "X-Key: $API_KEY" "$BASE&category=$CATS")
fi
case "$CODE" in
200) : ;;
304) logger -t reportedip "[$LIST] unchanged (304)" ; exit 0 ;;
*) logger -t reportedip "[$LIST] HTTP $CODE - keeping previous set" ; exit 1 ;;
esac
# The feed carries both families; ipset needs them in separate sets.
grep -E '^[0-9]+(\.[0-9]+){3}$' "$BODY" > "$BODY.v4" || true
grep -E '^[0-9a-fA-F:]+:[0-9a-fA-F:]*$' "$BODY" > "$BODY.v6" || true
COUNT=$(wc -l < "$BODY.v4")
if [ "$COUNT" -lt "$MIN" ]; then
logger -t reportedip "[$LIST] only $COUNT IPv4 entries (min $MIN) - keeping previous set"
exit 1
fi
# IPv4: build a scratch set, fill it in one restore pass, swap it in.
ipset create "rip-$LIST" hash:ip family inet maxelem 65536 -exist
ipset create "rip-$LIST-tmp" hash:ip family inet maxelem 65536 -exist
{ echo "flush rip-$LIST-tmp" ; sed "s|^|add rip-$LIST-tmp |" "$BODY.v4" ; } | ipset restore -exist
ipset swap "rip-$LIST-tmp" "rip-$LIST"
ipset destroy "rip-$LIST-tmp"
# IPv6: same pattern, own family. Skipped when the list has no v6 entries.
ipset create "rip-$LIST-v6" hash:ip family inet6 maxelem 65536 -exist
if [ -s "$BODY.v6" ]; then
ipset create "rip-$LIST-v6-tmp" hash:ip family inet6 maxelem 65536 -exist
{ echo "flush rip-$LIST-v6-tmp" ; sed "s|^|add rip-$LIST-v6-tmp |" "$BODY.v6" ; } | ipset restore -exist
ipset swap "rip-$LIST-v6-tmp" "rip-$LIST-v6"
ipset destroy "rip-$LIST-v6-tmp"
fi
awk 'tolower($1) == "etag:" { print $2 }' "$HEAD" | tr -d '\r' > "$ETAG"
logger -t reportedip "[$LIST] updated: $COUNT IPv4, $(wc -l < "$BODY.v6") IPv6"
Make it executable and give it a staggered hourly schedule:
chmod 750 /usr/local/sbin/reportedip-sync.sh
# /etc/cron.d/reportedip
7 * * * * root /usr/local/sbin/reportedip-sync.sh ssh
17 * * * * root /usr/local/sbin/reportedip-sync.sh mail
27 * * * * root /usr/local/sbin/reportedip-sync.sh web
37 * * * * root /usr/local/sbin/reportedip-sync.sh ftp
47 * * * * root /usr/local/sbin/reportedip-sync.sh edge
ipset save > /etc/ipset.conf plus your distribution's ipset service, or simply run
the sync script once at boot before the firewall rules load — the rules below reference the
sets by name, so an empty set fails open, not closed.
iptables rules
Install once, for example in your firewall bootstrap. Each rule matches its own set on its own ports.
ip6tables mirrors the same rules against the -v6 sets.
# IPv4
iptables -I INPUT -p tcp --dport 22 -m set --match-set rip-ssh src -j DROP
iptables -I INPUT -p tcp -m multiport --dports 25,465,587,110,995,143,993 \
-m set --match-set rip-mail src -j DROP
iptables -I INPUT -p tcp -m multiport --dports 80,443 \
-m set --match-set rip-web src -j DROP
iptables -I INPUT -p tcp --dport 21 -m set --match-set rip-ftp src -j DROP
iptables -I INPUT -m set --match-set rip-edge src -j DROP
# IPv6
ip6tables -I INPUT -p tcp --dport 22 -m set --match-set rip-ssh-v6 src -j DROP
ip6tables -I INPUT -p tcp -m multiport --dports 25,465,587,110,995,143,993 \
-m set --match-set rip-mail-v6 src -j DROP
ip6tables -I INPUT -p tcp -m multiport --dports 80,443 \
-m set --match-set rip-web-v6 src -j DROP
ip6tables -I INPUT -p tcp --dport 21 -m set --match-set rip-ftp-v6 src -j DROP
ip6tables -I INPUT -m set --match-set rip-edge-v6 src -j DROP
Native nftables
On nftables systems (Debian 12, RHEL 9 and newer default to it) you can skip ipset entirely. Named
sets live inside the ruleset, an inet table holds IPv4 and IPv6 side by side, and
nft -f applies a whole file as one transaction — flush and refill in the same
atomic step, with no scratch set and no swap.
One-time setup
nft add table inet reportedip
nft add chain inet reportedip input '{ type filter hook input priority -10 ; policy accept ; }'
for l in ssh mail web ftp edge ; do
nft add set inet reportedip "rip-$l" '{ type ipv4_addr ; }'
nft add set inet reportedip "rip-$l-v6" '{ type ipv6_addr ; }'
done
nft add rule inet reportedip input tcp dport 22 ip saddr @rip-ssh drop
nft add rule inet reportedip input tcp dport 22 ip6 saddr @rip-ssh-v6 drop
nft add rule inet reportedip input tcp dport '{ 25, 110, 143, 465, 587, 993, 995 }' ip saddr @rip-mail drop
nft add rule inet reportedip input tcp dport '{ 25, 110, 143, 465, 587, 993, 995 }' ip6 saddr @rip-mail-v6 drop
nft add rule inet reportedip input tcp dport '{ 80, 443 }' ip saddr @rip-web drop
nft add rule inet reportedip input tcp dport '{ 80, 443 }' ip6 saddr @rip-web-v6 drop
nft add rule inet reportedip input tcp dport 21 ip saddr @rip-ftp drop
nft add rule inet reportedip input tcp dport 21 ip6 saddr @rip-ftp-v6 drop
nft add rule inet reportedip input ip saddr @rip-edge drop
nft add rule inet reportedip input ip6 saddr @rip-edge-v6 drop
Update step
Replace the ipset block of the sync script with this. Everything else — conditional fetch, size check, family split, logging — stays as it is.
# Single transaction: both families flushed and refilled, or nothing changes.
{
echo "flush set inet reportedip rip-$LIST"
echo "flush set inet reportedip rip-$LIST-v6"
[ -s "$BODY.v4" ] && printf 'add element inet reportedip rip-%s { %s }\n' \
"$LIST" "$(paste -sd, "$BODY.v4")"
[ -s "$BODY.v6" ] && printf 'add element inet reportedip rip-%s-v6 { %s }\n' \
"$LIST" "$(paste -sd, "$BODY.v6")"
} | nft -f -
Persist the ruleset the usual way, for example:
nft list ruleset > /etc/nftables.conf
systemctl enable nftables
IPv6
The feed returns IPv4 and IPv6 in the same list. IPv6 is a small share of it today — attackers still run mostly on v4 — but it is not zero, and it is the part of the list that grows. Two rules follow from that:
- Never mix families in one ipset. A
family inetset rejects every v6 address with "Element cannot be added to the set: it's not in the set's family", and a naive loop aborts the whole update on the first v6 line. The script above splits the body before loading, so a v6 entry can never break the v4 set. - Match v6 explicitly.
iptablesrules never see IPv6 traffic. Without the matchingip6tablesrules (or aninetnftables table), an attacker with working IPv6 walks past a perfectly maintained block set.
Blocking a single v6 address is weak on its own — a /64 assignment gives an attacker
effectively unlimited addresses. Treat v6 entries as a signal for the surrounding prefix: for
sustained abuse, block the /64 with a hash:net set (ipset) or a set with
flags interval (nftables), and keep the per-address list for everything else.
Cloud and edge firewalls
If traffic reaches a CDN or cloud edge before it reaches your kernel, blocking there saves the bandwidth and the connection. The list is the same; only the delivery differs.
Cloudflare
Maintain a custom IP list at account level and reference it from a WAF custom rule. The bulk endpoint
replaces all items in one call, which is the same atomic-swap idea as ipset swap. Custom
lists take individual addresses and CIDR ranges (IPv4 /8–/32, IPv6
/12–/128); how many items and lists you get depends on your plan, so
check your quota before pushing a 10,000-entry list.
# Replace all items of an existing list (returns an async operation id)
curl -sS -X PUT \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/rules/lists/$CF_LIST_ID/items" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$(jq -R -s 'split("\n") | map(select(length > 0) | {ip: .})' < "$BODY.v4")"
Then a single WAF custom rule does the blocking, for example
(ip.src in $rip_web) with action Block. Because the rule is one expression, list
size does not affect rule count.
AWS
Use a WAF IP set, not security groups or prefix lists: an IP set holds up to 10,000 addresses or CIDR
ranges and is referenced by one rule, whereas security-group and prefix-list entries count individually
against per-resource rule quotas and run out long before 10,000. Addresses must be in CIDR notation,
so single IPs need a /32.
LOCK=$(aws wafv2 get-ip-set --scope REGIONAL --name rip-web --id "$IPSET_ID" \
--query 'LockToken' --output text)
aws wafv2 update-ip-set --scope REGIONAL --name rip-web --id "$IPSET_ID" \
--lock-token "$LOCK" \
--addresses $(sed 's|$|/32|' "$BODY.v4" | head -n 10000 | paste -sd' ')
Other providers
Hetzner Cloud, Azure NSGs, GCP firewall policies and most managed firewalls cap the number of rules or
CIDRs per policy well below 10,000. Where the cap bites, keep the edge for the edge list
only (DDoS, port scans, exploited hosts — the smallest and most clear-cut set) and run the
service lists in the kernel on the host, where set size costs nothing.
Verify it works
# How many entries are live right now?
ipset list rip-ssh | head -n 8
nft list set inet reportedip rip-ssh | head -n 8
# Is a specific IP in the set?
ipset test rip-ssh 1.2.3.4
# What did the last sync do?
journalctl -t reportedip --since "2 hours ago"
# Are packets actually hitting the rule?
iptables -L INPUT -v -n --line-numbers | grep -i match-set
Before you switch to DROP
- Whitelist your own address ranges, management networks, monitoring and backup systems in the
firewall first — independent of this feed. An
ACCEPTrule placed above the set rules is enough. - Run each list in log-only mode for 48 hours (
-j LOGinstead of-j DROP, orloginstead ofdropin nftables) and read what would have been blocked. - Roll out one list at a time. Start with
ssh: it has the clearest attack signal and the smallest blast radius. - Keep the minimum-size check. It is the one guard that stops a bad response from wiping your protection.
- If someone reports being blocked, the evidence for any address is public at
https://reportedip.com/ip/<ip>/, and delisting runs through IP Delisting.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
401 or 403 |
Key missing, wrong header name, or a tier without threat-feed access. See Authentication |
Always 304, set never fills |
A stale tag file from a different list or format. Delete
/var/lib/reportedip/etag-* and run again — tags are per variant,
so never share one file across lists |
| Element cannot be added to the set: it's not in the set's family | IPv6 addresses being fed into a family inet set. Split the body by family
as the script does |
| Set cannot be destroyed: it is in use | You are destroying the live set instead of the scratch set. Only the
-tmp set is destroyed, after the swap |
| Update takes minutes | One ipset add per line. Use ipset restore in a single pass,
as shown above |
| Sets empty after reboot | ipset contents are not persisted by default. Run the sync at boot or save and restore
/etc/ipset.conf |
| Attacks continue despite a full set | Traffic arrives over IPv6, or an earlier ACCEPT rule matches first. Check
rule order with iptables -L INPUT -v -n --line-numbers |
Report back
Consuming the list is one direction. The attacks your own server sees can flow back into it: a
ready-made fail2ban action posts every ban to POST /report with the threat category that
matches the jail. Reports charge only your daily report quota, never the check quota, and every report
strengthens the lists you pull. See the
fail2ban integration guide.
Quick reference
| Endpoint | GET https://reportedip.com/wp-json/reportedip/v2/blacklist |
| Auth | X-Key: YOUR_API_KEY header |
| Base parameters | confidence=90&format=txt&limit=50000 |
| SSH | &category=22,18 |
&category=11,7,17,18 | |
| Web (incl. WordPress) | &category=21,16,10,19,12,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58 |
| FTP | &category=5,18 |
| Edge | &category=4,14,20 |
| Server refresh | every 15 minutes |
| Recommended polling | hourly per list, staggered, with If-None-Match |
| Quota impact | none — the endpoint counts against neither check nor report quota |
| Category catalogue | GET /categories (no auth) |
Last updated: · Maintained by the ReportedIP team