SSH Hardening Checklist for Ubuntu Servers

Lock down SSH on Ubuntu 24.04 without locking yourself out: keys only, no root, the cloud-init drop-in trap, socket activation, verification.

Share
SSH Hardening Checklist for Ubuntu Servers

An SSH port on a public IP gets login attempts within minutes of the box coming up. That's not a threat in itself — it's background noise — but it means password logins and root logins are a bet you don't need to make. This is the checklist I go through on every new Ubuntu server. It's short, and almost all of it boils down to: keys only, no root, and actually verify the config is what you think it is.

TL;DR: Key auth working first, then one drop-in file, test, reload.

# on your machine
ssh-keygen -t ed25519 -C "you@laptop"
ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@server

# on the server
sudo nano /etc/ssh/sshd_config.d/00-hardening.conf    # contents below
sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -Ei 'passwordauth|permitroot|kbdinteractive'

Keep your current session open the whole time. Test with a new terminal before closing anything.

Step 0: a sudo user with a key

Don't harden anything until you can log in as a non-root user with a key and that user can sudo. If you're still on root with a password, set that up first — user management with adduser, sudo and SSH keys walks through it. Ed25519 keys are short, fast, and supported by every OpenSSH version you'll meet. No reason to generate RSA in 2026 unless some ancient client forces you.

Check the key login actually works, from a second terminal:

ssh -i ~/.ssh/id_ed25519 admin@server 'sudo -v && echo ok'

The drop-in file

Ubuntu's /etc/ssh/sshd_config includes /etc/ssh/sshd_config.d/*.conf at the very top. I don't edit the main file at all — package updates like to ask about it — and put everything in a drop-in:

# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no

MaxAuthTries 3
LoginGraceTime 30
MaxStartups 10:30:60

X11Forwarding no
AllowAgentForwarding no

ClientAliveInterval 300
ClientAliveCountMax 2

AllowUsers admin

What matters here:

  • PermitRootLogin no — log in as yourself, sudo when needed. If you have automation that genuinely needs root over SSH, prohibit-password allows root with a key only. I'd still rather not.
  • PasswordAuthentication no + KbdInteractiveAuthentication no — both, not just the first one. Keyboard-interactive can also end up asking for a password through PAM. (ChallengeResponseAuthentication is the old, deprecated name for the second one.)
  • AuthenticationMethods publickey — belt and braces: only public key, nothing else, no matter what other settings say.
  • MaxAuthTries 3 — fewer guesses per connection. Careful if you have many keys in your agent: SSH tries them one after another and you may get Too many authentication failures before it reaches the right one. Fix that on the client with IdentitiesOnly yes and an IdentityFile per host in ~/.ssh/config.
  • MaxStartups 10:30:60 — throttles concurrent unauthenticated connections. Starts dropping 30% of new ones above 10, all above 60.
  • ClientAliveInterval / CountMax — dead sessions get cleaned up after about 10 minutes of no response.
  • AllowUsers — only these accounts can log in over SSH. Everyone else is rejected, even with a valid key. Space-separated, and user@10.0.0.* patterns work for source restrictions.

I leave AllowTcpForwarding alone because I use SSH tunnels to reach internal dashboards. If you don't, set it to no too.

Gotcha: the cloud-init file wins

This is the one that makes people think SSH hardening "doesn't work". For most sshd_config keywords, the first value sshd reads wins, not the last. Drop-in files are read in alphabetical order. Cloud images on Ubuntu often ship a file like /etc/ssh/sshd_config.d/50-cloud-init.conf containing:

PasswordAuthentication yes

If you name your file 99-hardening.conf, the 50- file is read first, its value sticks, and your PasswordAuthentication no is ignored. Silently. That's why mine is 00-hardening.conf. Check what's there on your box:

ls /etc/ssh/sshd_config.d/
grep -r . /etc/ssh/sshd_config.d/

And never trust the file — trust sshd -T, which prints the effective config:

sudo sshd -T | grep -Ei 'passwordauthentication|permitrootlogin|kbdinteractive|allowusers|maxauthtries'

If that says passwordauthentication yes, something earlier is overriding you.

Gotcha: socket activation on Ubuntu 24.04

Since Ubuntu 22.10, SSH is socket-activated by default: ssh.socket listens on the port and starts ssh.service on demand. For normal config changes systemctl reload ssh is fine. But port and listen address are now handled by the socket unit, generated from your sshd_config at boot / daemon-reload. If you change Port or ListenAddress:

sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
sudo ss -tlpn | grep ssh

Skip that and sshd keeps listening on the old port while you wonder why the new one is dead. Debian 12 and 13 don't use socket activation by default — there it's just systemctl restart ssh.

About changing the port at all: it's not security. It cuts log noise a lot, which is nice, but anything targeted finds it in one scan. If you change it, open the new port in the firewall first — UFW rules that don't lock you out has the safe order.

Verify from the outside

Config looks right? Prove it. From your machine:

# should fail with: Permission denied (publickey).
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password admin@server

# should fail — root is not allowed
ssh root@server

# should work
ssh admin@server

If the first command gives you a password prompt, password auth is still on. Go back to sshd -T.

Gotcha: locking yourself out

The rules I don't break:

  1. Keep the existing session open. A reload doesn't kill established sessions.
  2. Always sudo sshd -t before reloading. It prints nothing if the config is valid, and an error with the line number if not.
  3. Test the login in a second terminal before closing the first.
  4. Know where the provider's web/VNC console is before you need it.

AllowUsers deserves a special mention: a typo in your own username there and you're out. Same for AuthenticationMethods with a method your client doesn't offer.

Beyond the config file

These aren't sshd settings, but they belong on the checklist:

  • Rate-limit and ban. Key-only auth makes brute force pointless, but the log noise stays. fail2ban or CrowdSec cleans that up.
  • Don't expose SSH at all. The strongest option: SSH only on a VPN interface. That's the setup in my WireGuard post, or use Tailscale if you don't want to manage keys yourself.
  • Firewall. Allow 22 (or your port) from where you actually connect, if that's a fixed IP or range.
  • Updates. OpenSSH gets security fixes. Unattended upgrades take care of that without you thinking about it.
  • Audit. sudo apt install ssh-audit and run ssh-audit localhost. On current Ubuntu and Debian the default algorithms are already decent; it'll mostly point out legacy stuff that's still allowed for compatibility.

Watch the logs

sudo journalctl -u ssh -f
sudo journalctl -u ssh --since today | grep -c 'Invalid user'

On a public IP you'll see a steady stream of Invalid user and Connection closed by authenticating user lines. That's fine. That's the internet. What you want to see is that none of them get anywhere — and with keys only, no root and an AllowUsers list, they won't.

This checklist is one step in my initial server setup — I do it right after creating the user and before installing anything else.


Related posts