</>CodeWithKarani

Your fail2ban Stopped Banning Anyone and Never Told You

Karani GeoffreyKarani Geoffrey8 min read

I was auditing a client's VPS in Nairobi last year, one of those boxes that had been running quietly since 2022. fail2ban was installed, enabled, and running. systemctl status fail2ban was a beautiful green dot. Then I ran fail2ban-client status sshd and it said Total banned: 0.

Zero. On a public IPv4 address, port 22 open, for two and a half years. Meanwhile lastb had thousands of failed logins. fail2ban was watching a file that sshd had stopped writing to after a distro upgrade moved authentication logging into the journal.

This is fail2ban's worst property: its failure mode is silence, and its success mode is also silence. A jail that is working perfectly and a jail whose regex no longer matches anything produce identical output on a quiet day. Nobody sets an alert on "nothing happened", so nobody finds out.

after any upgrade of sshd, nginx, your app, or the OS itself, re-validate every jail against a current log sample:

  • fail2ban-client status <jail> to see whether it has found or banned anything recently.
  • fail2ban-client get <jail> logpath and get <jail> journalmatch to confirm it is reading the source that actually receives the lines today.
  • fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-missed to prove the filter still matches real lines.
  • Alert on zero bans over 7 days as an anomaly, not just on bans occurring.

The symptom: a healthy service that has never banned anyone

Start here on every server you own. It takes ten seconds.

sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- File list:        /var/log/auth.log
`- Actions
   |- Currently banned: 0
   |- Total banned:     0
   `- Banned IP list:

Total failed: 0 is the number that should frighten you. Currently banned being 0 is normal, bans expire. Total banned being 0 could mean a recently restarted service. But Total failed: 0 means fail2ban has not recognised a single failed authentication since it last started. On any internet-facing box that has been up more than a few hours, that is not peace. That is blindness.

Compare against the ground truth, which fail2ban is not involved in:

sudo lastb | head -20
sudo journalctl -u ssh --since "24 hours ago" | grep -c "Failed password"

If lastb is full and fail2ban's Total failed is zero, the link between the two is broken. Now you find out where.

Why fail2ban goes deaf

fail2ban is a log scraper. It is three fragile couplings in a trench coat: a source (a file path or a journal match), a filter (regexes that must match the exact text of a line), and an action (a firewall command that must still work). An upgrade can break any one of them without touching the other two, and without producing an error, because "no lines matched" is a perfectly valid state.

grep -E "Found|Ban " /var/log/fail2ban.log | tail -20 No "Found" lines at all Broken SOURCE or FILTER. The jail is reading nothing, or reading lines it cannot parse. → run fail2ban-regex "Found" lines but no "Ban" lines Working filter, wrong thresholds. maxretry not reached inside findtime, or IP in ignoreip. → check maxretry/findtime "Ban" lines but the attacker keeps connecting Broken ACTION. banaction mismatched to your firewall, or the rule sits below an ACCEPT. → inspect nftables/iptables
Three failure classes, one grep. Do this before you touch a config file, because each branch leads somewhere completely different.

The specific things that break, roughly in order of how often I have found them:

What changedWhat brokeHow to detect it
Distro upgrade moved auth logging to the journal, /var/log/auth.log no longer writtenSource: jail polls a file that never growsls -l --time-style=full-iso /var/log/auth.log shows an old mtime
You changed nginx log_format or switched to JSON access logsFilter: every custom nginx failregex missesfail2ban-regex reports 0 matched, N missed
App upgrade changed its failed-login log wordingFilter: your hand-written app jail matches nothingSame, against the app log
Migration from iptables to nftablesAction: ban is recorded but never enforcedBans in the log, no rules in nft list ruleset
Log rotation writes to a new inode and the backend does not followSource: reads a deleted file handleBans stop within a day of rotation and resume on restart
Your monitoring or CI runner IP ended up in ignoreipThresholds: findings without bansfail2ban-client get sshd ignoreip

The post-upgrade check, step by step

Step 1: Confirm which source the jail is actually reading

Do not read jail.local and assume. fail2ban merges jail.conf, jail.d/*.conf and jail.local, and on Debian and Ubuntu a packaged drop-in often sets backend = systemd behind your back. Ask the running server what it resolved to:

sudo fail2ban-client get sshd logpath
sudo fail2ban-client get sshd journalmatch

If logpath returns a file, check that the file is still being written:

stat -c '%y %n' /var/log/auth.log

An mtime from last year means sshd is logging somewhere else now. If journalmatch returns something like _SYSTEMD_UNIT=sshd.service + _COMM=sshd, verify that unit name still exists. On recent Ubuntu the SSH unit is ssh.service, with sshd.service only present as an alias, and on socket-activated setups the connections arrive under ssh@.service instances instead. A journalmatch pinned to the wrong unit name matches nothing and reports no error.

Step 2: Run the filter against a real, current log sample

This is the single highest-value command in this article, and it is the one nobody runs:

sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-missed

For a systemd backend, point it at the journal instead:

sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf \
  -m "_SYSTEMD_UNIT=ssh.service"

You are looking at the summary at the bottom:

Results
=======

Failregex: 412 total
|-  #) [# of hits] regular expression
|   1) [412] ^Failed \S+ for .* from <HOST>( port \d+)?...
`-

Lines: 90210 lines, 0 ignored, 412 matched, 89798 missed

matched: 0 with a non-zero lastb is a broken filter, full stop. --print-all-missed then shows you the real lines so you can see how the wording changed. A high missed count on its own is fine, most log lines are not failed logins.

Step 3: Test the action chain without waiting for an attacker

Filters and actions fail independently, so test the action separately. Ban a documentation IP that nobody owns:

sudo fail2ban-client set sshd banip 198.51.100.7
sudo nft list ruleset | grep -A5 f2b        # or: sudo iptables -L -n | grep 198.51.100.7
sudo fail2ban-client set sshd unbanip 198.51.100.7

If the ban appears in fail2ban.log but not in the firewall, your banaction does not match the firewall backend on this machine. That happens on every iptables-to-nftables migration and it is invisible until you look.

Step 4: Rebuild the jail against the new format

When the log wording has genuinely changed, do not edit files under /etc/fail2ban/filter.d/ directly, they are package-owned and the next upgrade will overwrite you. Create /etc/fail2ban/filter.d/myapp.local or a new filter file, and iterate with fail2ban-regex against a saved log sample until it matches. Then reload without dropping existing bans:

sudo fail2ban-client reload sshd

Verification: prove it in the logs, not in the config

Wait a few minutes on a public box, then read fail2ban's own log. You want to see both verbs:

sudo grep -E "Found|NOTICE" /var/log/fail2ban.log | tail -20
2026-07-19 22:14:03,881 fail2ban.filter  [913]: INFO    [sshd] Found 203.0.113.44 - 2026-07-19 22:14:03
2026-07-19 22:14:11,204 fail2ban.filter  [913]: INFO    [sshd] Found 203.0.113.44 - 2026-07-19 22:14:11
2026-07-19 22:14:19,663 fail2ban.actions [913]: NOTICE  [sshd] Ban 203.0.113.44

Found proves the source and the filter work. Ban proves the thresholds and the action work. Then check fail2ban-client status sshd again and confirm Total failed is climbing. On a systemd box where fail2ban logs to the journal instead of a file, the same lines come from journalctl -u fail2ban -n 50.

What people get wrong

"I restarted fail2ban and it is fine now." Restarting resets the counters to zero, which makes Total failed: 0 look explainable. You have destroyed the only evidence you had. Diagnose first, restart second.

"No bans means no attacks." On a public IPv4 address there are always attacks. If you are on a cheap VPS in a common hosting range, the SSH scanners find you within an hour of the IP being allocated. Zero findings over a week is a broken jail, not a quiet internet.

"I set bantime to -1 so they are permanently banned." Permanent bans do nothing about a filter that no longer matches, and they grow an unbounded firewall set that eventually costs you memory and rule-evaluation time. Time-boxed bans plus a working filter beat permanent bans plus a dead filter every single time.

"fail2ban is our rate limiting." It is not. It reacts after N failures inside findtime, with log-flush latency on top. A burst of a few hundred requests per second is over before fail2ban has parsed the first line. Log scraping is a second layer behind real limits, not the first one. Put actual limits in nginx, as in the nginx reverse proxy and rate limiting setup, and in your application. On a small VPS on a metered link, the bandwidth of a bot flood costs you money before any ban lands, which I covered in surviving a bot flood on a cheap VPS.

Make the silence audible

The real fix is not a config change, it is turning the absence of events into an event. Two things worth doing:

  1. Alert on zero. A weekly cron that reads Total failed for each jail and shouts if any of them is zero. Not a dashboard, an actual message you will read.
  2. Put it in the upgrade checklist. Any time you upgrade sshd, nginx, or the application whose log you scrape, run fail2ban-regex against a current sample from that service before you close the ticket.
#!/usr/bin/env bash
# /usr/local/bin/f2b-canary.sh - run weekly, shout if a jail has gone deaf
for jail in $(fail2ban-client status | sed -n 's/.*Jail list:\s*//p' | tr ',' ' '); do
  failed=$(fail2ban-client status "$jail" | awk -F'\t' '/Total failed/ {print $2}' | tr -d ' ')
  if [ "${failed:-0}" -eq 0 ]; then
    echo "WARNING: fail2ban jail '$jail' has found nothing since last restart" >&2
  fi
done

Send the output somewhere a human reads. Point it at the same alerting path you use for certificate renewals, which has the identical failure shape: a thing that quietly stops working and only tells you when it is already too late. I wrote that one up in certbot renewal silent failures.

When it is still broken

  1. Bans land but the attacker still gets through. Your ban rule is being evaluated after an ACCEPT rule, or Docker has inserted its own chain ahead of yours. Dump the full ruleset in order and read it top to bottom.
  2. IPv6 traffic is unbanned. Older configurations only ban v4. Confirm the action creates a v6 set as well, and remember that a v6 attacker has a whole /64 to rotate through, so single-address bans are weak there.
  3. Behind Cloudflare or a load balancer, every ban is the proxy. Your filter is extracting the proxy address as <HOST>. Fix the upstream log format to record the real client address first, then fix the filter.
  4. Nothing matches and the lines look identical. Check the timestamp format. fail2ban has to parse the date to apply findtime, and a changed date pattern makes valid matches get discarded as too old. Run fail2ban-regex with -v and look at what it thinks the dates are.

The reference for filter syntax and the fail2ban-regex options is the project's own documentation at fail2ban.readthedocs.io. Read it once, then go and check your servers. I would bet a decent amount that at least one of your jails has been asleep for months.

Frequently asked questions

How do I know if fail2ban is actually banning anyone?
Run fail2ban-client status sshd and look at Total failed, not Currently banned. Currently banned is often zero because bans expire, but Total failed being zero on a public server means fail2ban has not recognised a single failed login since it last started. Cross-check with lastb or journalctl: if those show failures and fail2ban shows none, the filter or the log source is broken.
Why did fail2ban stop working after I upgraded the OS?
Most often the log source moved. Distribution upgrades increasingly send authentication logging to the systemd journal instead of /var/log/auth.log, so a jail configured with a file logpath polls a file that never grows again. Run fail2ban-client get sshd logpath and fail2ban-client get sshd journalmatch to see what the running server resolved to, then check that source is still being written.
How do I test a fail2ban filter without waiting for a real attack?
Use fail2ban-regex against a current log sample: fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-missed. For a systemd backend, pass systemd-journal as the log argument with -m to supply a journal match. If it reports 0 matched lines while your logs clearly contain failed logins, the filter no longer matches the current log format.
Is fail2ban enough protection on its own?
No. It reacts only after several failures within findtime, with log-writing and polling latency on top, so a fast burst is finished before the first ban lands. Treat it as a second layer behind real rate limiting in your web server and application, and behind key-only SSH authentication. It also depends entirely on log parsing, which is exactly the coupling that silently breaks.
#fail2ban#Linux#SSH#Hardening#Nginx
Keep reading

Related articles