Skip to content

fail2ban

SSH Hardening mentioned fail2ban briefly, as one piece of a broader SSH checklist: it watches logs, and bans an IP address that produces too many failed attempts within a time window. This article is the full treatment — fail2ban isn't SSH-specific at all; it's a general log-watching framework that can protect any service with a log file and a recognizable failure pattern, from a mail server to a web application's login form.

What You Will Learn

By the end of this article you will be able to:

  • explain how fail2ban turns a log pattern into a firewall ban;
  • configure a jail in jail.local without editing the shipped defaults directly;
  • test a filter's regex against real log lines before trusting it in production;
  • check, unban, and whitelist addresses by hand;
  • protect a second service beyond SSH with its own jail.

How fail2ban Actually Works

fail2ban has three moving parts that combine into one jail:

  • a log file to watch (/var/log/auth.log, an application's own log, and so on);
  • a filter — a set of regular expressions that recognize a failed-attempt line in that log;
  • an action — what to do once the filter has matched too many times from one address within a time window, almost always inserting a firewall rule to block it.
sudo systemctl status fail2ban
● fail2ban.service - Fail2Ban Service
     Loaded: loaded (/lib/systemd/system/fail2ban.service; enabled)
     Active: active (running)

fail2ban itself doesn't replace the firewall — it manipulates it, inserting and removing ban rules dynamically as a nftables/iptables set (see iptables and nftables) that UFW or a raw nftables ruleset then still enforces underneath.

Never Edit the Shipped Defaults Directly

fail2ban ships with a default configuration in /etc/fail2ban/jail.conf, but that file is expected to be overwritten on the next package upgrade — any local customization belongs in /etc/fail2ban/jail.local, which fail2ban reads after jail.conf and which any setting there overrides:

sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Copying the whole file as a starting point is the conventional approach, but only the sections actually being customized need to remain — anything left unset falls back to jail.conf's default automatically.

Configuring a Jail

sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 203.0.113.5

[sshd]
enabled  = true
port     = 22
logpath  = %(sshd_log)s
maxretry = 3
  • bantime — how long a ban lasts once triggered (1h, or -1 for permanent);
  • findtime — the rolling window within which maxretry failures must occur to trigger a ban;
  • maxretry — how many matching failures within findtime trigger the ban;
  • ignoreip — addresses (and CIDR ranges) never banned regardless of how they match — always include the management workstation's own address here, since a ban on your own IP is exactly as effective at locking you out as one on an attacker's;
  • [sshd] overrides maxretry specifically for this jail, stacking on top of the [DEFAULT] section's values for everything not explicitly restated.
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 2
|  |- Total failed:     14
|  `- Journal matches:  _SYSTEMD_UNIT=ssh.service + _COMM=sshd
`- Actions
   |- Currently banned: 1
   |- Total banned:     3
   `- Banned IP list:   198.51.100.7

Testing a Filter Before Trusting It

A filter that fails to match real log lines silently protects nothing, with no error to indicate the problem — it's worth verifying a filter's regex actually matches before relying on it:

sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
Results
=======

Success, the total number of match is 14

Overall
=======
Lines: 20348 lines, 0 ignored, 14 matched, 20334 missed

fail2ban-regex runs the filter against the actual log file without banning anyone — the safe way to confirm a custom filter (or a modified one, after a distribution changes its log format) still matches before depending on it in production.

Checking, Unbanning, and Whitelisting

sudo fail2ban-client status
Status
|- Number of jail:      2
`- Jail list:   sshd, nginx-http-auth
sudo fail2ban-client set sshd unbanip 198.51.100.7
198.51.100.7

Unbanning is the correct response to a false positive — a legitimate user who mistyped their password too many times, for instance — rather than restarting the whole fail2ban service, which would also clear every other active ban unrelated to the mistake.

sudo fail2ban-client get sshd bantime

Permanent whitelisting for addresses that should never trigger a ban belongs in ignoreip, not repeated manual unbanning:

ignoreip = 127.0.0.1/8 ::1 203.0.113.5 198.51.100.0/28

Protecting a Second Service

fail2ban's design generalizes past SSH — any service with a log file and a recognizable failure line can get its own jail. A common example: banning repeated failed HTTP basic-auth attempts against an nginx site.

sudo nano /etc/fail2ban/jail.local
[nginx-http-auth]
enabled  = true
port     = http,https
logpath  = /var/log/nginx/error.log

fail2ban ships a matching filter (/etc/fail2ban/filter.d/nginx-http-auth.conf) for this exact log pattern already — the jail definition only needs to enable it and point at the right log path, since the filter itself is part of the standard distribution.

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

How This Relates to pam_faillock and UFW's limit

Three mechanisms in this course address a similar-sounding problem, and being able to distinguish them precisely is worth preparing for directly:

Mechanism Reacts to Scope of the block
ufw limit (see Firewall and UFW) Raw connection rate, no awareness of success/failure Per source IP, at the firewall, before authentication is even attempted
pam_faillock (see Root and sudo) Consecutive authentication failures for one account Per account, regardless of source IP
fail2ban Failure patterns in a log file, service-agnostic Per source IP, at the firewall, based on genuinely failed attempts

Interview angle: why deploy all three, not just one

A distributed brute-force attack spreading a few attempts per account across thousands of source IPs defeats fail2ban's per-IP banning (no single IP ever crosses the threshold) but not pam_faillock's per-account lockout; a slow, patient attacker rotating through many usernames from a single IP defeats pam_faillock's per-account counting but not fail2ban's per-IP banning. ufw limit catches neither pattern precisely but adds a coarse, cheap first line of defense that blocks obvious high-speed scanning before it even reaches the authentication layer. None of the three alone covers every attack shape — that's the practical argument for deploying all three together, not a redundancy to be trimmed.

Practical Scenario: Diagnosing a Jail That Isn't Banning Anything

Problem. SSH is clearly under active attack (dozens of failed logins visible in auth.log), but fail2ban-client status sshd shows zero bans.

Step 1 — confirm the jail is even enabled and watching the right log:

sudo fail2ban-client status sshd
ERROR   NOK: ('sshd',)

The jail doesn't exist at all — [sshd] was never set to enabled = true in jail.local, so jail.conf's own default (disabled, on this distribution) is what's actually in effect.

Step 2 — enable it and confirm the log path matches reality:

sudo nano /etc/fail2ban/jail.local
[sshd]
enabled = true
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0

Step 3 — verify the filter is actually matching the log format in use:

sudo fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf
Lines: 20348 lines, 0 ignored, 14 matched, 20334 missed

Conclusion. The jail was simply never enabled — a configuration gap, not a filter or firewall problem. Once enabled and confirmed to match real log lines, subsequent failed attempts start accumulating toward maxretry correctly.

Common Mistakes

  • Editing jail.conf directly. The next package upgrade overwrites it, silently discarding the customization; all local changes belong in jail.local.
  • Forgetting to add the management workstation's own IP to ignoreip. A single mistyped password from a shared office IP can lock out an entire team, not just the person who mistyped it.
  • Assuming a jail is enabled just because it's listed in the config file. As shown in the scenario above, a jail section can exist without enabled = true, in which case it does nothing.
  • Trusting a filter without testing it against real log lines. A log format change after a distribution upgrade can silently break a previously working filter — fail2ban-regex is the way to confirm it still matches before relying on it.
  • Using fail2ban as the only defense against brute-force login attempts. As covered above, it addresses a different attack shape than pam_faillock and ufw limit; relying on it alone leaves specific attack patterns uncovered.

Exercises

  1. On a lab VM, enable the sshd jail with a low maxretry (2) and a short bantime (60), then deliberately fail SSH login from another machine or terminal enough times to trigger a ban. Confirm with fail2ban-client status sshd and unban yourself afterward.
  2. Add your own management IP to ignoreip and explain, in one sentence, why this line is as important for availability as the jail itself is for security.
  3. Run fail2ban-regex against a service's log file and its corresponding filter, and interpret the "matched" versus "missed" counts in the output.
  4. For a hypothetical mail server under a password-spraying attack (many accounts, few attempts each, from one IP), explain which of fail2ban, pam_faillock, and ufw limit would catch it first, and why.

Summary

fail2ban watches a log file for a recognizable failure pattern and bans the offending address at the firewall once a threshold is crossed within a time window — a general mechanism, not an SSH-specific one, configured through jail.local rather than the overwritable jail.conf. It complements, rather than replaces, pam_faillock's per-account lockout and UFW's raw rate limiting, since each of the three catches a different attack shape. The next article, auditd Basics, covers recording security-relevant kernel events directly — a finer-grained, more general form of the same "watch, then act" idea.

References