A guide to hardening SSH on Ubuntu 24.04, checked against what OpenSSH actually does on a fresh server.
#1 sshd configuration hardening
Step 1 - Check what is already in sshd_config.d
Ubuntu's /etc/ssh/sshd_config includes /etc/ssh/sshd_config.d/*.conf, above every other setting. sshd reads those files in name order and keeps the first value it finds for each option. So a file named 00-something beats the cloud image's 50- or 60- file, and both beat sshd_config itself.
See what your image ships:
$ ls /etc/ssh/sshd_config.d/
60-cloudimg-settings.conf
$ grep -r . /etc/ssh/sshd_config.d/
/etc/ssh/sshd_config.d/60-cloudimg-settings.conf:PasswordAuthentication no
This one already turns passwords off. A provider that hands you a password often has cloud-init write PasswordAuthentication yes into 50-cloud-init.conf instead, and then a no in sshd_config changes nothing.
NOTE: If you have hardened sshd_config directly in the past, check with sudo sshd -T (Step 3) that those settings actually took effect.
Step 2 - Create the drop-in
$ sudo vi /etc/ssh/sshd_config.d/00-hardening.conf
# Keys only
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
AuthenticationMethods publickey
PermitRootLogin no
# Fewer guesses per connection, and less time to make them
MaxAuthTries 3
LoginGraceTime 30
# Drop a client that stops answering, after about 10 minutes
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
LogLevel VERBOSE
What each group does:
- Keys only. KbdInteractiveAuthentication is a second way for sshd to ask for a password, through PAM. Ubuntu's sshd_config already sets it to
no; setting it here as well means no other file can turn it back on.AuthenticationMethods publickeyrequires a key whatever else gets enabled. - PermitRootLogin no. Ubuntu's default,
without-password(the old name forprohibit-password), still lets root log in with a key. Log in as a sudo user instead. If root is the only account with a key, useprohibit-passworduntil you have one. - MaxAuthTries 3 and LoginGraceTime 30, down from 6 tries and 120 seconds. Every key your client offers counts as one try.
- ClientAliveInterval 300 with ClientAliveCountMax 2. Every 5 minutes of silence, sshd checks on the client through the encrypted channel, and it disconnects after two unanswered checks.
- LogLevel VERBOSE. Accepted logins are logged with the key's fingerprint at the default level already. VERBOSE also logs every rejected key, for example the provider's key you removed.
NOTE: With MaxAuthTries 3, a client that offers four keys is cut off before the fourth:
Received disconnect from 203.0.113.10 port 22:2: Too many authentication failures
Disconnected from 203.0.113.10 port 22
If your agent holds several keys, point each host at its own in ~/.ssh/config:
Host web-01
HostName 203.0.113.10
User ubuntu
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Step 3 - Test the config, then reload
$ sudo sshd -t
$ sudo systemctl reload ssh
sshd -t prints nothing when the config is valid. A reload leaves your current session open.
Then check the values sshd actually uses:
$ sudo sshd -T | grep -E '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin|maxauthtries) '
maxauthtries 3
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
If one still shows the old value, another file is winning: look in sshd_config.d again, and for Match blocks in sshd_config.
Step 4 - Test from a new terminal
Keep your current session open. From a new terminal:
$ ssh web-01 'echo still in'
still in
Then make sure a password is refused outright:
$ ssh -o PubkeyAuthentication=no ubuntu@203.0.113.10
ubuntu@203.0.113.10: Permission denied (publickey).
Only publickey should be listed. If password or keyboard-interactive shows up, something is still offering them.
#2 Restrict inbound traffic to your SSH port
The best place for this is the provider's firewall or security group, so unwanted traffic never reaches the server. On the server itself, with UFW:
-
Allow your trusted network first:
$ sudo ufw allow from 198.51.100.0/24 to any port 22 proto tcp comment 'SSH from office' -
Open a new SSH connection from that network to confirm it works.
-
Then remove the rule that allows everyone:
$ sudo ufw status numbered To Action From -- ------ ---- [ 1] 22/tcp ALLOW IN Anywhere [ 2] 22/tcp ALLOW IN 198.51.100.0/24 # SSH from office [ 3] 22/tcp (v6) ALLOW IN Anywhere (v6) $ sudo ufw delete allow 22/tcpThis removes both the IPv4 and IPv6 "Anywhere" rules and leaves yours.
NOTE: If your office IP changes, the provider's web console is the only way back in. A VPN such as Tailscale or WireGuard avoids that: allow SSH only from the VPN instead.
#3 Enable fail2ban
Step 1 - Install
$ sudo apt install fail2ban
$ fail2ban-client --version
Fail2Ban v1.0.2
Step 2 - Configure through jail.d
Ubuntu's package already enables the sshd jail, in /etc/fail2ban/jail.d/defaults-debian.conf:
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
backend = systemd
[sshd]
enabled = true
Do not edit jail.conf; the package owns it. Files in jail.d ending in .local are read last, so they override both:
$ sudo vi /etc/fail2ban/jail.d/sshd.local
[DEFAULT]
bantime = 1h
bantime.increment = true
bantime.maxtime = 1w
findtime = 10m
maxretry = 5
banaction = ufw
banaction_allports = ufw
[sshd]
enabled = true
mode = aggressive
- bantime.increment makes each new ban of the same address longer than the last, up to bantime.maxtime of a week.
- banaction = ufw puts the bans in
ufw status, next to the rule from #3. - mode = aggressive also counts connections that fail before trying a login, such as scanners that drop out of the key exchange.
- maxretry = 5, rather than the often-suggested 3. In aggressive mode one failed connection often counts twice: six test logins with a wrong user counted as 12 failures. With passwords off nobody can guess their way in anyway, so I leave some room for a teammate fumbling a key.
Step 3 - Test and restart
$ sudo fail2ban-client --test
2026-10-24 14:30:20,947 fail2ban.configreader [2914]: WARNING 'allowipv6' not defined in 'Definition'. Using default one: 'auto'
OK: configuration test is successful
$ sudo systemctl restart fail2ban
The allowipv6 warning comes from the ufw action and is harmless. After a bot's six attempts:
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
| |- Currently failed: 1
| |- Total failed: 12
| `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
|- Currently banned: 1
|- Total banned: 1
`- Banned IP list: 192.0.2.50
$ sudo ufw status
Status: active
To Action From
-- ------ ----
Anywhere REJECT 192.0.2.50 # by Fail2Ban after 5 attempts against sshd
22/tcp ALLOW 198.51.100.0/24 # SSH from office
To lift a ban, for example on yourself:
$ sudo fail2ban-client set sshd unbanip 192.0.2.50
1
Step 4 - The log
$ sudo tail /var/log/fail2ban.log
... fail2ban.filter [2919]: INFO [sshd] Found 192.0.2.50 - 2026-10-24 14:30:25
... fail2ban.filter [2919]: INFO [sshd] Found 192.0.2.50 - 2026-10-24 14:30:25
... fail2ban.actions [2919]: NOTICE [sshd] Ban 192.0.2.50
#4 Optional - Hide the Ubuntu patch level
sshd's greeting includes the exact Ubuntu package revision, which tells a scanner which security fixes you have:
SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.20
DebianBanner no in the drop-in trims it to SSH-2.0-OpenSSH_9.6p1. The OpenSSH version itself always shows, so this is a small win, in the same spirit as nginx's server_tokens off.
Checking who logs in
This is where LogLevel VERBOSE pays off:
$ sudo journalctl -t sshd --since today | grep -E 'Accepted publickey|Failed publickey|Too many'
Failed publickey for ubuntu from 198.51.100.20 port 37510 ssh2: ED25519 SHA256:ohnV3q5GqgwhmcFm/Zzg39Pln9XhdfwPSX2zWykh6Wg
Failed publickey for ubuntu from 198.51.100.20 port 37510 ssh2: ED25519 SHA256:IMeMRpRp9PlLsXu62ftccMK+AoAcjujlaqGFeUSHvME
Failed publickey for ubuntu from 198.51.100.20 port 37510 ssh2: ED25519 SHA256:Oez4pI4DTM1sXRqo+YYRjXPoMXUjbWRjao0YOchossQ
Disconnecting authenticating user ubuntu 198.51.100.20 port 37510: Too many authentication failures [preauth]
Accepted publickey for ubuntu from 198.51.100.20 port 57924 ssh2: RSA SHA256:sIQD/TBtb0AV4WLr3gASwJhRsYb1j7wdo88YdY815vg
To see whose key a fingerprint belongs to, run ssh-keygen -lf ~/.ssh/authorized_keys on the server.