Log-based ban tools are useful. They are also late.
By the time Fail2ban or a custom parser sees “too many auth failures,” the noisy source has already burned CPU, conntrack slots, and application workers. Sometimes you do not even have a useful log line — just a flood of new TCP handshakes or HTTP requests that never look like a clean signature.
nftables can act earlier. With dynamic sets, element timeouts, and meters (per-key rate limits), the firewall itself can:
- rate-limit new connections per source IP
- rate-limit by source IP and destination port
- temporarily ban sources that cross a threshold
- cap concurrent connections per source with
ct count - expire all of that state automatically without a userspace daemon
This post is a practical recipe for public Linux hosts and small edge gateways using stock nftables.
What you are solving
Three common edge problems:
- Connection spam — one IP opens hundreds of new TCP sessions per minute against SSH, HTTPS, or an API.
- Request floods that stay under SYNPROXY — handshakes complete, so cookie defense is happy, but the app still melts.
- Sticky bans without a ban manager — you want “block this source for 10 minutes,” then forget it.
Fail2ban + nftables still makes sense when the signal lives in application logs (invalid passwords, scanner signatures, WAF events). Dynamic sets and meters cover the packet-path half of the problem: cheap, early, and independent of log shipping.
| Tool | Signal | Typical action |
|---|---|---|
| tcp_syncookies / nftables SYNPROXY | unauthenticated TCP handshakes | survive SYN floods / protect conntrack |
| nftables meters + dynamic sets | packet/connection rates in Netfilter | throttle or temp-ban in kernel |
| Fail2ban + nft set | log lines / auth failures | ban after app-visible abuse |
| tc HTB / CAKE | queueing and bandwidth | shape fairness, not abuse identity |
Prerequisites
- Linux with nftables (dynamic set updates need nftables v0.7+; modern Debian/Ubuntu/Fedora are fine)
- Kernel support for the set expressions you use (
limit,ct countneeds 4.18+ for the connlimit-style pattern) - root on the host that sees the traffic (local
input, orforwardon a gateway) - A baseline firewall you understand — do not paste these snippets as your only ruleset on a remote box without a recovery path
Packages (Debian/Ubuntu-style):
sudo apt-get update
sudo apt-get install -y nftables
sudo nft -v
Before changing anything live:
sudo nft list ruleset > "/root/nftables-before-dynamic-$(date +%F-%H%M).nft"
Core ideas (short)
Named sets with timeout
From the nftables set docs:
-
timeout— default lifetime for elements -
flags timeout— elements may carry timeouts -
flags dynamic— elements (and attached stateful expressions) can be created from the packet path -
size— hard cap on membership (protect memory under attack) - optional per-element
counter
add vs update
When a rule writes into a set from the packet path:
-
add @set { ... }— insert if missing; does not refresh an existing element’s timeout -
update @set { ... }— insert or refresh; resets timeout on every matching packet
That difference matters. Rate-limit “seen recently” tables usually want update. One-shot ban lists often want add so the ban clock is not extended by the attacker’s own flood.
Meters ≈ modern hashlimit
The nftables meters documentation is explicit: dynamic sets plus stateful expressions replace iptables hashlimit / much of connlimit. The common pattern is:
update @set { ip saddr limit rate 3/minute } accept
For each new matching flow/packet, nftables keys the set by ip saddr, attaches a rate limiter to that element, and refreshes the element timeout while traffic continues.
Recipe 1 — Per-source rate limit on new SSH connections
Goal: allow a handful of new SSH handshakes per source per minute; quietly drop the rest. Established sessions stay untouched.
sudo tee /etc/nftables.d/ssh-meter.nft >/dev/null <<'EOF'
table inet edge_protect {
set ssh_ratelimit {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 2m
}
chain input_ssh_meter {
type filter hook input priority filter; policy accept;
# Only meter fresh connection attempts
ct state new tcp dport 22 \
update @ssh_ratelimit { ip saddr limit rate 6/minute burst 6 packets } \
accept
# Optional: explicit drop of excess new SSH (if an earlier rule would accept port 22)
ct state new tcp dport 22 counter drop
}
}
EOF
sudo nft -c -f /etc/nftables.d/ssh-meter.nft
sudo nft -f /etc/nftables.d/ssh-meter.nft
sudo nft list set inet edge_protect ssh_ratelimit
How to read the rule
-
ct state new— only first packets of a connection, not every segment of an open session -
update @ssh_ratelimit { ip saddr limit rate 6/minute ... }— create/refresh a per-source limiter -
burst 6 packets— short allowance for reconnect bursts / multiplexed clients -
timeout 2mon the set — idle keys expire so the set does not grow forever -
size 65535— stops unbounded memory growth if millions of unique sources appear
IPv6 twin (same table, second set):
set ssh_ratelimit6 {
type ipv6_addr
size 65535
flags dynamic,timeout
timeout 2m
}
ct state new meta nfproto ipv6 tcp dport 22 \
update @ssh_ratelimit6 { ip6 saddr limit rate 6/minute burst 6 packets } accept
Or use separate ip / ip6 tables if you prefer not to mix families.
Recipe 2 — Concatenated keys (source IP + destination port)
One source hammering many ports is different from one source talking only to HTTPS. Concatenations let one set key on both selectors (kernel 4.1+):
table inet edge_protect {
set per_ip_port {
type ipv4_addr . inet_service
size 131072
flags dynamic,timeout
timeout 3m
}
chain input_port_meter {
type filter hook input priority filter; policy accept;
ct state new tcp dport { 80, 443, 8080 } \
update @per_ip_port { ip saddr . tcp dport limit rate 40/minute burst 20 packets } \
accept
}
}
The meters wiki shows the same shape for SSH:
ct state new update @my_ssh_ratelimit { ip saddr . tcp dport limit rate 3/minute } accept
Use concatenations when the fair unit of abuse is (client, service), not just the client.
Recipe 3 — Soft throttle, then temporary ban
Rate limits alone still spend some work on every excess packet. For scanners you want a quarantine list: after they trip, drop them cold for a while.
Pattern:
- Early rule: if source is already in
@blacklist, drop. - Metered accept for legitimate new traffic.
- On over-limit (or on a second “trap” path),
addthe source into@blacklistwith a timeout.
sudo tee /etc/nftables.d/temp-ban.nft >/dev/null <<'EOF'
table inet edge_protect {
set blacklist {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 15m
# optional: counter # per-element counters if your nft/kernel supports it
}
set http_ratelimit {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 2m
}
chain input_http {
type filter hook input priority filter; policy accept;
# 1) Already banned? Drop immediately.
ip saddr @blacklist counter drop
# 2) New HTTP(S): allow under per-source rate.
ct state new tcp dport { 80, 443 } \
update @http_ratelimit { ip saddr limit rate 30/minute burst 15 packets } \
accept
# 3) Excess new HTTP(S): ban source for the set default timeout, then drop.
# Use add (not update) so the flood does not keep refreshing the ban forever.
ct state new tcp dport { 80, 443 } \
add @blacklist { ip saddr timeout 15m } \
counter drop
}
}
EOF
sudo nft -c -f /etc/nftables.d/temp-ban.nft
sudo nft -f /etc/nftables.d/temp-ban.nft
Manual operator ban (same set, no packet path required):
# Ban one host for 1 hour
sudo nft add element inet edge_protect blacklist { 203.0.113.50 timeout 1h }
# Inspect
sudo nft list set inet edge_protect blacklist
sudo nft get element inet edge_protect blacklist { 203.0.113.50 }
# Lift early
sudo nft delete element inet edge_protect blacklist { 203.0.113.50 }
Why add on the ban path: the nftables “Updating sets from the packet path” page notes that update refreshes timeouts on every match, while add does not. If you update the blacklist from the attacker’s own packets, a continuous flood can pin the entry forever. add starts the clock once.
Recipe 4 — Concurrent connection cap with ct count
Some abuse is not “packets per second” but “too many simultaneous sessions” (slowloris-style pressure, scraper pools, broken clients).
nftables meters docs (kernel 4.18+):
table ip filter {
set my_connlimit {
type ipv4_addr
size 65535
flags dynamic
}
chain input {
type filter hook input priority filter; policy accept;
# Drop new connections once this source already has > 20 tracked conns
ct state new tcp dport { 80, 443 } \
add @my_connlimit { ip saddr ct count over 20 } \
counter drop
}
}
Important constraints from the official meters page:
- Use
add, notupdate, withct count - Do not set a timeout on that set — conntrack timers own element lifetime
- Combining
ct countwith set timeouts returns Operation is not supported
Tune the number from real ss / conntrack baselines, not from a blog default.
Wire it into a real host policy
Dynamic sets are not a full firewall. Drop them into a normal default-deny input policy. Minimal skeleton (inspired by the nftables “simple ruleset for a server” wiki page):
#!/usr/sbin/nft -f
flush ruleset
table inet firewall {
set blacklist {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 30m
}
set ssh_ratelimit {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 2m
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "lo" accept
# Neighbor discovery (IPv6 connectivity)
icmpv6 type { nd-neighbor-solicit, nd-router-advert,
nd-neighbor-advert, nd-redirect } accept
# Global temp bans
ip saddr @blacklist counter drop
# SSH: meter, then accept under limit
ct state new tcp dport 22 \
update @ssh_ratelimit { ip saddr limit rate 6/minute burst 6 packets } \
accept
# SSH over limit → short ban
ct state new tcp dport 22 \
add @blacklist { ip saddr timeout 10m } \
counter drop
# Public web
ct state new tcp dport { 80, 443 } accept
# Everything else inbound stays dropped by policy
}
chain forward {
type filter hook forward priority filter; policy drop;
}
}
Persist the usual way on Debian/Ubuntu:
# Prefer includes if you already split rules
sudo mkdir -p /etc/nftables.d
# ensure /etc/nftables.conf includes /etc/nftables.d/*.nft OR paste the table there
sudo systemctl enable --now nftables
sudo nft -c -f /etc/nftables.conf
sudo systemctl reload nftables
Gateway / forward path
On a router or hypervisor edge, the same ideas move to forward and often key on the WAN ingress interface:
table inet edge_forward {
set wan_blacklist {
type ipv4_addr
size 131072
flags dynamic,timeout
timeout 20m
}
set wan_new_conn {
type ipv4_addr
size 131072
flags dynamic,timeout
timeout 2m
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "eth0" ip saddr @wan_blacklist counter drop
# Example: meter new connections toward an internal HTTPS backend
iifname "eth0" oifname "eth1" ct state new tcp dport 443 \
update @wan_new_conn { ip saddr limit rate 60/minute burst 30 packets } \
accept
iifname "eth0" oifname "eth1" ct state new tcp dport 443 \
add @wan_blacklist { ip saddr timeout 20m } \
counter drop
}
}
Replace interface names. If you use flowtables for established traffic, keep new/ban decisions on the classic path before flow add — meters and blacklist checks belong where every new flow still visits Netfilter.
Verification checklist
1. Sets exist and accept dynamic updates
sudo nft list sets
sudo nft list set inet edge_protect ssh_ratelimit
sudo nft list set inet edge_protect blacklist
You should see flags dynamic,timeout (or dynamic only for ct count sets).
2. Generate controlled load in a lab
From a lab client you own (never third-party infrastructure):
# Many new TCP connects toward SSH on a lab host
for i in $(seq 1 30); do
nc -zw1 192.0.2.10 22 || true
done
Then:
sudo nft list set inet edge_protect ssh_ratelimit
sudo nft list set inet edge_protect blacklist
Expect the client IP to appear under the meter set, and under blacklist if you crossed the ban path.
3. Confirm established sessions are not collateral damage
Open one legitimate SSH session before the flood test. It should survive while new attempts from the noisy source are limited — because meters match ct state new, not established.
ss -Htn state established 'dport = :22 or sport = :22'
4. Watch expiry
sudo nft list set inet edge_protect blacklist
# wait past timeout
sleep 60
sudo nft list set inet edge_protect blacklist
Elements should show expires ... while alive and disappear after timeout/GC.
5. Counters move
sudo nft list chain inet edge_protect input_http
The ban/drop rules should accumulate packets under synthetic load.
Operational pitfalls
Putting meters on
establishedtraffic
You will throttle bulk transfers and break long sessions. Stick toct state newunless you intentionally want byte/packet rate limits on data plane traffic.Using
updatefor ban lists
Attack traffic refreshes the timeout and can make bans eternal. Preferadd+ fixedtimeout.Forgetting
size
Under a distributed flood of unique sources, unbounded sets become a memory problem. Always setsize.IPv4-only sets on dual-stack hosts
Attackers will walk in on IPv6. Mirror sets withipv6_addror use carefully designedinetrules.NAT / CGNAT shared addresses
Per-source IP limits punish whole NATed populations. Raise thresholds, key on more specific concatenations where possible, or move enforcement closer to identity (auth, API keys).Assuming this replaces application auth
It does not. It reduces cheap abuse. Stolen credentials and slow authenticated abuse still need app controls.Reloading with
flush rulesetin production
Dynamic set membership is runtime state. A full flush clears bans and meters. Prefer additive table loads or atomic replace strategies you have tested.Ordering mistakes
Blacklist drops must run before broadtcp dport ... acceptrules. Meter-accept rules must run before the over-limit ban rule.
Rollback
# Remove only the tables/files you added
sudo nft delete table inet edge_protect 2>/dev/null || true
sudo nft delete table inet edge_forward 2>/dev/null || true
# Or restore the pre-change dump
# sudo nft -f /root/nftables-before-dynamic-YYYY-MM-DD-HHMM.nft
sudo systemctl reload nftables 2>/dev/null || true
Keep console/out-of-band access when testing default-deny policies remotely.
Minimal production recipe
# 1) Backup
sudo nft list ruleset > "/root/nftables-before-dynamic-$(date +%F).nft"
# 2) Install a small dynamic meter + blacklist table (edit ports/rates first)
sudo nft -c -f /etc/nftables.d/temp-ban.nft
sudo nft -f /etc/nftables.d/temp-ban.nft
# 3) Verify empty sets and hooks
sudo nft list table inet edge_protect
# 4) Lab-only connect storm from a host you own, then:
sudo nft list set inet edge_protect blacklist
sudo nft list set inet edge_protect http_ratelimit
# 5) Manual ban / unban when needed
# sudo nft add element inet edge_protect blacklist { 203.0.113.50 timeout 1h }
# sudo nft delete element inet edge_protect blacklist { 203.0.113.50 }
Closing
If your only abuse control waits on application logs, you are defending with a diary instead of a door.
nftables dynamic sets and meters give you a kernel-resident middle ground:
- rate-limit new work per source (and per service)
- quarantine repeat offenders for a bounded time
- cap concurrent connections with
ct count - expire state automatically without babysitting a ban daemon
Use SYNPROXY when unauthenticated handshakes are the cost center. Use Fail2ban when the signal is in logs. Use dynamic sets and meters when the packet path already knows enough to slow or drop the noise.
Sources and references
- nftables wiki — Sets (timeout, flags, size, dynamic elements): https://wiki.nftables.org/wiki-nftables/index.php/Sets
- nftables wiki — Meters (dynamic set rate limits, hashlimit translation,
ct count): https://wiki.nftables.org/wiki-nftables/index.php/Meters - nftables wiki — Updating sets from the packet path (
addvsupdate): https://wiki.nftables.org/wiki-nftables/index.php/Updating_sets_from_the_packet_path - nftables wiki — Concatenations: https://wiki.nftables.org/wiki-nftables/index.php/Concatenations
- nftables wiki — Simple ruleset for a server: https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_server
- Debian
nft(8)manpage: https://manpages.debian.org/bookworm/nftables/nft.8.en.html - Netfilter nftables manpage hub: https://www.netfilter.org/projects/nftables/manpage.html
Top comments (0)