Skip to main contentSkip to footer

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.

Not what you need? To protect a single WordPress site, install the Hive plugin instead — it does the lookups per request, no firewall work required. To score inbound mail, query the DNS/RBL zone instead of downloading lists. File formats and the GitHub mirror are described on the Blacklist page.

How it works

1

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.

2

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.

3

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:

text
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.

bash
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"
ParameterValueWhy
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.

confidenceTypical sizeUse 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

  1. Poll hourly, staggered. Five lists once an hour is 120 requests per day. Do not fire all five in the same second.
  2. Send the ETag back. Store the ETag response header per list and return it as If-None-Match; an unchanged list answers 304 Not Modified with an empty body. The tag covers the exact variant you requested, including the category filter, so keep one tag file per list.
  3. 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.
  4. 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.
  5. 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.

bash
#!/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:

bash
chmod 750 /usr/local/sbin/reportedip-sync.sh
cron
# /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
Sets do not survive a reboot. Persist them with 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.

bash
# 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

bash
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.

bash
# 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:

bash
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 inet set 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. iptables rules never see IPv6 traffic. Without the matching ip6tables rules (or an inet nftables 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.

bash
# 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.

bash
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

bash
# 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 ACCEPT rule placed above the set rules is enough.
  • Run each list in log-only mode for 48 hours (-j LOG instead of -j DROP, or log instead of drop in 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

SymptomCause 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

EndpointGET https://reportedip.com/wp-json/reportedip/v2/blacklist
AuthX-Key: YOUR_API_KEY header
Base parametersconfidence=90&format=txt&limit=50000
SSH&category=22,18
Mail&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 refreshevery 15 minutes
Recommended pollinghourly per list, staggered, with If-None-Match
Quota impactnone — the endpoint counts against neither check nor report quota
Category catalogueGET /categories (no auth)

Last updated: · Maintained by the ReportedIP team

Security Focused
GDPR Compliant
Made in Germany
Back to Docs