Security & detectionbeginner5 min read

Detecting and blocking SSH brute force: sshd config, fail2ban, and what fail2ban doesn't see

How to see in two minutes who is hammering your SSH port, which five sshd settings make 99% of attacks pointless, and how to configure fail2ban correctly on Ubuntu 24.04 (the systemd backend trap). Then: the three attack patterns fail2ban is blind to.

Contents
  1. Step 1: see who's at the door right now
  2. Step 2: five sshd settings that change the game
  3. Step 3: rate limit at the firewall
  4. Step 4: fail2ban — with the right backend
  5. Step 5: alert on what matters
  6. Pitfalls
  7. What you still don't have
  8. How monsys does it
  9. FAQ

Put a server with a public IP online and within an hour someone tries root with 123456. That's not an attack on you; it's the background noise of the internet. It only becomes a problem if you allow password login, if one of your users has a weak password, or if you don't notice the pattern changing from "everyone tries root" to "someone is trying your usernames". This article sets up detection and blocking with what's already on the server.

Step 1: see who's at the door right now

On Ubuntu 24.04 and Debian 12, sshd logs to the journal. /var/log/auth.log only exists if rsyslog is installed — on cloud images it often isn't.

# Failed logins today, count
journalctl -u ssh --since today --no-pager | grep -c 'Failed password'
# Debian also calls the unit ssh.service; on some systems sshd.service
journalctl -u sshd --since today --no-pager | grep -c 'Failed password'

# Top 10 source IPs
journalctl -u ssh --since today --no-pager | grep 'Failed password' \
  | grep -oE 'from [0-9a-f.:]+' | sort | uniq -c | sort -rn | head

# Which usernames are they trying? Distinguish existing (Failed password for USER)
# from non-existent (invalid user USER)
journalctl -u ssh --since today --no-pager | grep -oE 'invalid user [^ ]+' | sort | uniq -c | sort -rn | head
journalctl -u ssh --since today --no-pager | grep -E 'Failed password for [^i]' | grep -oE 'for [^ ]+' | sort | uniq -c | sort -rn | head

# And: who DID get in, from where?
journalctl -u ssh --since "7 days ago" --no-pager | grep -E 'Accepted (publickey|password)' \
  | awk '{print $1, $2, $3, $9, $11}' | sort -k4

The last command is the most important and the least run. A thousand failed attempts are noise; one Accepted publickey for deploy from 185.x.x.x from an IP you don't recognise is an incident.

Step 2: five sshd settings that change the game

Everything goes in /etc/ssh/sshd_config.d/10-hardening.conf (Ubuntu and Debian read that directory; leave the main file alone):

sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
# 1. No passwords. Brute-forcing keys is pointless.
PasswordAuthentication no
KbdInteractiveAuthentication no

# 2. root never logs in directly. sudo leaves a trail, root doesn't.
PermitRootLogin no

# 3. Only these accounts may enter via SSH. Anything not listed doesn't exist to sshd.
AllowUsers deploy jeroen

# 4. Fewer attempts, shorter window.
MaxAuthTries 3
LoginGraceTime 20

# 5. No unused channels.
X11Forwarding no
AllowAgentForwarding no
EOF

# Syntax check BEFORE reloading — a mistake here locks you out.
sudo sshd -t && sudo systemctl reload ssh

Keep your current session open and test in a second terminal that you can still get in. Only then close the first.

What's deliberately not in there: a different port. Port 2222 reduces the noise in your logs, not the attack. Scanners find the port in seconds, and you lose the ability to distinguish SSH traffic on port 22 in firewalls and netflow. Do it only for quieter logs, and don't call it security.

Step 3: rate limit at the firewall

Even before fail2ban: let the kernel do the heavy lifting. With ufw:

sudo ufw limit 22/tcp comment 'ssh rate-limit'
# = max 6 new connections per 30 s per source IP, then drop

Or directly in nftables if you don't use ufw:

sudo nft add rule inet filter input tcp dport 22 ct state new limit rate over 6/minute burst 6 packets drop

This costs nothing, needs no daemon and completely stops the dumb, fast brute force.

Step 4: fail2ban — with the right backend

fail2ban reads logs, counts failures per IP and temporarily adds a firewall rule. The trap on Ubuntu 24.04 and Debian 12: the default configuration looks for /var/log/auth.log, and that file doesn't exist if rsyslog is missing. The jail then starts fine but never bans. Force the systemd backend.

sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
backend  = systemd
bantime  = 1h
findtime = 10m
maxretry = 5
# Escalation: whoever comes back after a ban stays away longer
bantime.increment = true
bantime.factor    = 2
bantime.maxtime   = 1w
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8

[sshd]
enabled = true
mode    = aggressive   # also counts "invalid user" and pre-auth disconnects
EOF
sudo systemctl enable --now fail2ban

# Is it working?
sudo fail2ban-client status sshd
sudo fail2ban-client status sshd | grep 'Currently banned'

If "Currently failed" is still 0 after a day while journalctl -u ssh is full of failures, the backend is wrong. fail2ban-client get sshd logpath should be empty (systemd) or point to an existing file.

Manual ban and unban:

sudo fail2ban-client set sshd banip 203.0.113.7
sudo fail2ban-client set sshd unbanip 203.0.113.7

Step 5: alert on what matters

Failed logins don't need to wake you up; fail2ban and ufw handle those. These three things do, and for that you need a small script:

sudo tee /usr/local/sbin/ssh-watch.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
NTFY="https://ntfy.example.be/security"
HOST=$(hostname -s)
KNOWN=/etc/ssh-watch.known   # one IP or prefix per line that you trust
ALERTS=()
touch "$KNOWN"

# 1. Successful login from an unknown IP (last 15 min)
while read -r user ip; do
  grep -q "^${ip%.*}" "$KNOWN" || ALERTS+=("login $user from UNKNOWN $ip")
done < <(journalctl -u ssh --since "15 min ago" --no-pager | grep -E 'Accepted (publickey|password)' | awk '{print $9, $11}')

# 2. Attempts on REAL usernames (not 'invalid user') — someone knows your accounts
real=$(journalctl -u ssh --since "15 min ago" --no-pager | grep -E 'Failed password for [^i]' | wc -l)
[ "$real" -gt 20 ] && ALERTS+=("$real failed attempts on EXISTING users in 15 min")

# 3. Slow, distributed brute force: > 50 unique source IPs in an hour, each < 5 attempts
uniq_ips=$(journalctl -u ssh --since "1 hour ago" --no-pager | grep -oE 'from [0-9a-f.:]+' | sort | uniq -c | awk '$1<5' | wc -l)
[ "$uniq_ips" -gt 50 ] && ALERTS+=("distributed: $uniq_ips low-rate source IPs in 1 h")

if [ ${#ALERTS[@]} -gt 0 ]; then
  printf '%s\n' "${ALERTS[@]}" | curl -s -H "Title: ssh@$HOST" -H "Priority: urgent" --data-binary @- "$NTFY" >/dev/null
fi
EOF
sudo chmod 0755 /usr/local/sbin/ssh-watch.sh
echo '*/15 * * * * root /usr/local/sbin/ssh-watch.sh' | sudo tee /etc/cron.d/ssh-watch

Fill /etc/ssh-watch.known with the first three octets of your office and VPN ranges. Yes, that's crude. It's also working within an hour and catches the alert you really want: someone is in from a place you don't know.

Pitfalls

  • Locking yourself out. Always sshd -t before reload, always a second session open, and on cloud VPSes: know where the console is in the panel before you start.
  • fail2ban bans your own office. A colleague picking the wrong key file three times triggers maxretry. Put your own ranges in ignoreip.
  • Forgetting IPv6. ufw limit 22/tcp covers v4 and v6; hand-written nftables rules often don't. Check with ss -tn state established '( sport = :22 )' whether there are v6 connections.
  • AllowUsers and automation. A backup or deploy account not on the list fails silently. Add it, or use AllowGroups ssh-users and manage the group.
  • Journal rotation. journalctl --since "7 days ago" only works if the journal is kept that long. Set SystemMaxUse= and MaxRetentionSec=30day in journald.conf; otherwise during an incident you see nothing older than yesterday.

What you still don't have

fail2ban and the script see one host and one log. Three patterns slip through:

  1. Geography. A successful login from an IP not in known is an alert — but "this user has never logged in from this country before" is a much sharper one. That needs GeoIP, preferably without a MaxMind licence (the RIR files from RIPE, ARIN, APNIC, AFRINIC and LACNIC are free).
  2. Fleet correlation. The same IP making one attempt on five of your servers bans nowhere and stands out nowhere. That's exactly how distributed attacks work.
  3. What happens after the login. A successful SSH login followed by a read of /root/.ssh/id_rsa.backup within two minutes isn't a sysadmin, it's an intruder. See the guide on honeypot files.

How monsys does it

The monsys agent does the detection on the host itself — no log line leaves the machine, only the signal: ssh_bruteforce (per source IP and per user, with window), invalid_user_enumeration, new_country_login (GeoIP from RIR data, no licence) and geo_blocked_country. The hub correlates across all hosts of a tenant, and the lateral movement detection links a honeypot trip to an internal SSH login within five minutes. Blocking remains a human decision, via a signed Emergency Action Token — monsys never acts on its own.

FAQ

Is it safe to disable password login?

Yes, provided you first put your public key in ~/.ssh/authorized_keys and tested that key login works. Do that in a second session before closing the first. With PasswordAuthentication no, brute-forcing passwords is by definition hopeless.

Why doesn't fail2ban ban anything on Ubuntu 24.04?

Usually because /var/log/auth.log doesn't exist (no rsyslog) and fail2ban reads that file by default. Set backend = systemd in jail.local and restart fail2ban. Check with fail2ban-client status sshd.

Does moving SSH to another port help?

It reduces the number of log lines, not the risk. Port scanners find an alternative port within seconds. At most combine it with key-only login and rate limiting; on its own it's not a security measure.

Written by the monsys team — sysadmins who do this every day.

Done it by hand? Let monsys keep it running.

Everything in this guide runs in monsys as a continuous check, with history, alerts and audit evidence. 5 servers free, EU-hosted in Belgium, installed in 60 seconds.