Block SSH Brute Force with fail2ban
Match auth failures with a jail, ban repeat offenders with iptables or nftables, whitelist your own networks, and verify with a test ban.
Task
Deploy automatic banning of brute-forcing addresses and prove the ban triggers, expires, and never locks out legitimate operators.
When to use this
The auth log shows one address attempting thousands of passwords nightly. Manual blocking decays as soon as the attacker rotates to a new address.
Prerequisites
- A host with sshd logs and fail2ban installed
- nftables or iptables available for enforcement
- Knowledge of the operator IP ranges that must never be banned
Steps
1. Configure the sshd jail with sane thresholds.
[sshd]
enabled = true
maxretry = 4
findtime = 10m
bantime = 1h
bantime.increment = true
bantime.factor = 4
ignoreip = 127.0.0.1/8 203.0.113.0/24
Expected output: Four failures in ten minutes earns an hour-long ban, doubling per repeat up the factor, while operator networks are exempt.
2. Point the jail at your actual log source.
backend = systemd
journalmatch = _SYSTEMD_UNIT=sshd.service
Expected output: The jail reads failures from journald directly, robust against log rotation and format drift.
3. Verify the jail sees failures.
fail2ban-client status sshdExpected output: The status output shows currently failed and total failed counts climbing as attempts occur.
4. Test the ban path with a deliberate failure.
ssh -o PubkeyAuthentication=no -o PasswordAuthentication=yes wronguser@host 2>/dev/null; fail2ban-client status sshd | grep "Currently banned"Expected output: After the threshold, the address appears in the banned list and further connections from it drop at the firewall.
5. Confirm bans expire and escalations work.
sleep 3600 && fail2ban-client status sshd | grep -E "Banned|Total banned" ; fail2ban-client get sshd banip 203.0.113.77 2>/dev/nullExpected output: The test ban lifts after its duration, and repeat offenders accumulate progressively longer sentences.
Troubleshooting
Problem
Jail shows failures but no bans
Solution
The banaction backend mismatches the firewall in use; set banaction to nftables-multiport (or the iptables equivalent) matching the host.
Problem
Legitimate operator got banned
Solution
ignoreip missed their range; add it, then unban immediately with fail2ban-client set sshd unbanip, and audit the whitelist source of truth.
Problem
Attacker rotates addresses faster than bans
Solution
Bans cannot beat a distributed botnet alone; pair with key-only auth, which removes the password attack surface entirely.
Limitations
- fail2ban reacts to log patterns; log format changes on upgrade can blind it, so monitor its own error log.
- Address-based banning is powerless against legitimate users behind a shared NAT the attacker also uses; whitelists must be precise.
Make repeated guessing expensive for the guesser
Brute force is cheap because failure is free: an address can try thousands of passwords at zero cost. fail2ban inverts the economics by watching auth logs and handing repeat offenders to the firewall, so their next packets never reach sshd. Incremental ban times make recidivism progressively expensive, and the ignore list keeps the mechanism from ever punishing its own operators.
[sshd]
enabled = true
maxretry = 4
findtime = 10m
bantime = 1h
bantime.increment = true
bantime.factor = 4
ignoreip = 127.0.0.1/8 203.0.113.0/24
Bans are the symptom metric
Track banned addresses and repeat-ban counts as an operational metric: a climbing curve means the exposure is real, and a flat one after switching to key-only auth confirms the surface closed. The ban list is also incident data, feeding the blocklists and the conversation about whether password auth deserves to exist at all.
Whitelist from a source of truth
The ignoreip list is the one line that can turn hardening into self-inflicted outage. Populate it from your documented operator ranges and CI runners, review it whenever infrastructure changes, and verify every entry with a deliberate failed attempt from inside the whitelist. Precision here is what makes the aggressive defaults safe.