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:

  1. 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 publickey requires a key whatever else gets enabled.
  2. PermitRootLogin no. Ubuntu's default, without-password (the old name for prohibit-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, use prohibit-password until you have one.
  3. MaxAuthTries 3 and LoginGraceTime 30, down from 6 tries and 120 seconds. Every key your client offers counts as one try.
  4. 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.
  5. 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:

  1. Allow your trusted network first:

    $ sudo ufw allow from 198.51.100.0/24 to any port 22 proto tcp comment 'SSH from office'
    
  2. Open a new SSH connection from that network to confirm it works.

  3. 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/tcp
    

    This 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
  1. bantime.increment makes each new ban of the same address longer than the last, up to bantime.maxtime of a week.
  2. banaction = ufw puts the bans in ufw status, next to the rule from #3.
  3. mode = aggressive also counts connections that fail before trying a login, such as scanners that drop out of the key exchange.
  4. 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.