DEV Community

kozhevniko
kozhevniko

Posted on

Hardening SSH Against Brute Force with Fail2ban and Rate Limiting

Hardening SSH Against Brute Force with Fail2ban and Rate Limiting

SSH is the primary administrative entry point on most Linux servers, which makes it the primary target as well. Internet-facing SSH daemons are scanned and probed continuously, and a misconfigured or under-monitored service can be the weakest link in an otherwise well-managed host. This tutorial walks through a layered approach: tightening the OpenSSH daemon itself, deploying Fail2ban with custom filters, adding TCP wrappers where appropriate, and verifying that lockouts actually work before relying on them.

Understanding the SSH Threat Landscape

Most SSH attacks are automated. Bots enumerate common usernames (root, admin, ubuntu, oracle) and try small dictionaries of passwords, then move on to the next host. Because the attempts are distributed and low-volume per source, a single IP rarely trips a simple connection limit. The practical consequences are log noise, wasted authentication cycles, and a small but real chance that a weak credential succeeds.

Three controls address this at different layers:

  • Daemon configuration reduces the attack surface by disabling weak authentication paths and limiting who can authenticate.
  • Rate limiting and banning (Fail2ban) responds dynamically to repeated failures by blocking the offending source for a period.
  • TCP wrappers provide a static allow/deny layer for hosts that still support /etc/hosts.allow and /etc/hosts.deny.

None of these replaces key-based authentication or a firewall. They are complementary, and each has failure modes you should understand before enabling it.

Configuring OpenSSH Daemon Parameters

Start with the daemon itself. The authoritative reference for every directive is the OpenSSH manual pages. The following settings form a reasonable baseline for a server that only needs key-based access:

# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
AllowUsers deploy admin
ClientAliveInterval 300
ClientAliveCountMax 2
Enter fullscreen mode Exit fullscreen mode

Notes on the choices:

  • PermitRootLogin no forces administrators to authenticate as a normal user and escalate. This removes the single most targeted account.
  • PasswordAuthentication no and KbdInteractiveAuthentication no eliminate password guessing entirely. Only enable these if you have a documented need; if you must keep passwords, pair them with Fail2ban.
  • MaxAuthTries 3 limits how many authentication attempts a single connection may make. Lower values increase lockout risk for legitimate users with misconfigured clients, so test before deploying.
  • AllowUsers restricts authentication to named accounts, which shrinks the set of valid usernames an attacker can discover.

Validate the configuration with sshd -t before restarting, and keep an existing session open while you test a new one. A syntax error in sshd_config combined with a restart can lock you out.

If your distribution ships sshd under systemd socket activation, the listening socket is owned by ssh.socket rather than the daemon. In that model, restarting ssh.service alone does not change the listening behavior; use systemctl restart ssh.socket (or ssh.service where the unit is named that way) and confirm with systemctl status. Socket activation is convenient, but it means connection-level limits configured in the daemon apply after the socket hands off the connection, so plan your rate limiting accordingly.

Deploying Fail2ban with Custom Filters

Fail2ban watches log files, matches patterns, and applies firewall rules when a threshold is crossed. Install it from your distribution's package repository, then create a local jail rather than editing the shipped jail.conf. Local overrides belong in jail.local:

# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
backend  = systemd

[sshd]
enabled = true
port    = ssh
logpath = %(sshd_log)s
Enter fullscreen mode Exit fullscreen mode

The backend = systemd setting matters on modern distributions where SSH logs go to the journal rather than a plain file. Without it, Fail2ban may see no events at all. Confirm which backend your system needs by checking whether /var/log/auth.log or /var/log/secure exists and is being written.

For finer control, write a custom filter. Filters live in /etc/fail2ban/filter.d/ and use Python regular expressions. A minimal filter targeting failed public-key and password attempts looks like this:

# /etc/fail2ban/filter.d/sshd-custom.conf
[Definition]
failregex = ^%(__prefix_line)sFailed \S+ for .*? from <HOST> port \d+ ssh2$
ignoreregex =
Enter fullscreen mode Exit fullscreen mode

The <HOST> token is what Fail2ban extracts and bans. Test the filter against real log data before enabling it:

fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd-custom.conf
Enter fullscreen mode Exit fullscreen mode

On a systemd-backed host, feed it journal output instead:

journalctl -u ssh --since "1 hour ago" | fail2ban-regex - /etc/fail2ban/filter.d/sshd-custom.conf
Enter fullscreen mode Exit fullscreen mode

The Fail2ban documentation covers filter syntax, actions, and jail options in detail, and the source repository is the place to check behavior changes between releases. After editing, reload with fail2ban-client reload and confirm the jail is active with fail2ban-client status sshd.

Implementing TCP Wrappers as a First Line of Defense

TCP wrappers filter incoming connections against /etc/hosts.allow and /etc/hosts.deny before the service sees them. Support depends on how the daemon was built; on many current distributions the libwrap integration has been removed from OpenSSH, so verify before relying on it. Run sshd -V or check the build options, and test with a connection from a known host.

If wrappers are compiled in, a default-deny posture is straightforward:

# /etc/hosts.deny
sshd : ALL

# /etc/hosts.allow
sshd : 203.0.113.0/24
sshd : 198.51.100.7
Enter fullscreen mode Exit fullscreen mode

Order matters: hosts.allow is evaluated first, and a match there permits the connection regardless of hosts.deny. Keep the allow list small and current. The risk with wrappers is that a stale allow list locks out legitimate administrators after an IP change, and because the denial happens before authentication, there is no log entry in the SSH authentication log to explain it. Check /var/log/secure or the journal for refused connect messages if connections fail unexpectedly.

Where wrappers are unavailable, use the firewall instead. An nftables or iptables rule limiting new SSH connections per source address achieves a similar effect and is easier to audit.

Testing and Verifying Lockout Mechanisms

Never assume a ban works. Verify it from a host you can afford to lose access from, or from a separate test address.

  1. Confirm the jail is running: fail2ban-client status sshd should list the filter and current ban count.
  2. Trigger failures deliberately from a test host using a wrong key or password until maxretry is reached.
  3. Check that the source appears in the banned list: fail2ban-client status sshd and, depending on your firewall backend, nft list ruleset or iptables -L -n.
  4. Confirm the connection is actually refused from the banned host.
  5. Unban the test address with fail2ban-client set sshd unbanip <address> and confirm access returns.

Also verify that legitimate access still works from each allowed network, and that a user who mistypes a password a few times is not banned. Set maxretry with real user behavior in mind, not just attacker behavior.

Monitoring Logs for False Positives

Fail2ban is only as good as the logs it reads. Watch for bans that hit legitimate users, which usually show up as repeated bans of the same address or of addresses in your own ranges. Useful commands:

fail2ban-client status sshd
journalctl -u ssh --since "24 hours ago" | grep -i "failed"
grep -i "ban" /var/log/fail2ban.log
Enter fullscreen mode Exit fullscreen mode

If a legitimate user is banned regularly, the cause is usually a misconfigured client retrying aggressively, an expired key, or an automation script with stale credentials. Fix the client rather than raising maxretry indefinitely. If an entire subnet is being banned, consider adding it to ignoreip in jail.local so internal hosts are never locked out:

[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24
Enter fullscreen mode Exit fullscreen mode

Long-term Maintenance Strategies

A hardening baseline decays. Treat these as recurring tasks:

  • Review ignoreip and any TCP wrappers allow lists whenever office or VPN address ranges change.
  • Keep Fail2ban and OpenSSH updated; filter regexes occasionally need adjustment when log formats change.
  • Periodically re-run fail2ban-regex against current logs to confirm filters still match.
  • Audit sshd_config after distribution upgrades, since package updates can reintroduce defaults.
  • Keep an out-of-band access path (console or management network) so a bad rule never becomes a permanent outage.

Layering daemon hardening, Fail2ban, and static allow lists gives you defense in depth without depending on any single control. The goal is not to stop every probe, but to make automated attacks unproductive and to keep legitimate access predictable.

References

Top comments (0)