SSH hardening

Keys only, no root, minimal exposure.

Intermediate14 min · lesson 1 of 16

SSH (Secure Shell, the encrypted protocol you use to open a command line on a remote machine) is the front door to almost every Linux server. It listens on port 22, the numbered doorway that incoming network connections arrive on, and that door faces the whole internet. That is exactly why it is the most attacked service online. On any server with a public address, automated programs ring the doorbell thousands of times a day, trying username and password pairs one after another, all day, forever. You do not have to take that on faith. You can watch it happen.

Read the attack before you stop it

On a modern Linux machine, every login attempt gets written to the system log. journalctl (the command that reads systemd's log, where systemd is the manager that starts and supervises background programs on most current Linux distributions) lets you page through it. Ask it for the SSH service over the last hour and filter down to the failures.

~/secopslog — bash
$ sudo journalctl -u ssh --since "1 hour ago" | grep "Failed password" | tail -5
Jul 17 09:14:02 web01 sshd[20117]: Failed password for root from 45.148.10.62 port 51234 ssh2 Jul 17 09:14:05 web01 sshd[20119]: Failed password for invalid user admin from 193.32.162.44 port 40122 ssh2 Jul 17 09:14:09 web01 sshd[20121]: Failed password for invalid user postgres from 61.177.173.18 port 55810 ssh2 Jul 17 09:14:11 web01 sshd[20124]: Failed password for root from 45.148.10.62 port 51902 ssh2 Jul 17 09:14:14 web01 sshd[20126]: Failed password for invalid user ubuntu from 218.92.0.34 port 33440 ssh2

Every one of those lines is a bot, a hijacked machine somewhere running an attack script, guessing a password against accounts it hopes exist: root, admin, postgres, ubuntu. They are not being clever. They are trying volume. The way to shut down this entire category of attack is not a longer or stranger password. It is taking passwords out of the login path completely, so there is nothing left to guess.

Why keys beat passwords

A password is a code punched into a keypad. Given enough attempts a keypad code can be guessed, and these bots have nothing but time. A cryptographic key works on a different principle. Say you have a lock you can bolt onto a door, and one oddly shaped piece of metal that opens it. You can make thousands of copies of the lock and bolt them onto servers all over the world at no risk, because the lock by itself opens nothing. Only the single piece of metal works, and it never leaves your pocket. That is public-key authentication. The lock is your public key, which you hand out freely. The metal is your private key, which you guard. Guessing a valid private key by trial is arithmetically hopeless. An ed25519 key (a modern, fast key type built on elliptic-curve math) has so many possible values that trying them one by one would outlast the machine you are attacking by an unimaginable margin.

You make a key pair once, on your own laptop, not on the server. The private half stays with you. The public half is safe to copy anywhere.

~/secopslog — bash
$ ssh-keygen -t ed25519 -C "deploy@web01"
Generating public/private ed25519 key pair. Enter file in which to save the key (/home/you/.ssh/id_ed25519): Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /home/you/.ssh/id_ed25519 Your public key has been saved in /home/you/.ssh/id_ed25519.pub The key fingerprint is: SHA256:9lYQ8s0mR2c1fV7pKxg4dNbZ0tHhq3aWjLuE6oPQnRs deploy@web01 The key's randomart image is: +--[ED25519 256]--+ | .o+o | | . +.o | | + = . | | . B o | | S O . | | . = X o | | o.@ * | | ..E=B . | | .o=*o. | +----[SHA256]-----+

For the server to accept that key, its public half has to be listed there, in a file called authorized_keys inside the target user's ~/.ssh folder. ssh-copy-id does that for you. It logs in once the old way, appends your public key to that file, and leaves. From then on your key alone gets you in.

~/secopslog — bash
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@web01
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/you/.ssh/id_ed25519.pub" /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys deploy@web01's password: Number of key(s) added: 1 Now try logging into the machine, with: "ssh 'deploy@web01'" and check to make sure that only the key(s) you wanted were added.

One rule of order matters more than anything else in this lesson. Get key login working first, and only then turn passwords off. If you disable passwords before your key is in place, you will lock yourself out of the very server you are trying to secure. Log in with the key in a fresh terminal, confirm it works, then keep going.

The hardening file

sshd (the SSH daemon, the always-running background program that answers incoming connections) reads its settings from /etc/ssh/sshd_config. On Debian 12 and Ubuntu 22.04, that file opens with a line that pulls in every .conf file from a folder beside it called sshd_config.d. One design choice makes this easy to live with. Think of a stack of sticky notes on the door, and a guard who reads them from the top down. For almost every setting, sshd takes the first value it meets and ignores every later one. The include line sits near the top of the file, so anything you drop into sshd_config.d gets read before the shipped defaults further down. Your settings win, and you never touch the original file.

/etc/ssh/sshd_config
# Read first. This pulls your drop-in files in near the top of the config,
# and sshd honors the first value it sees for each setting.
Include /etc/ssh/sshd_config.d/*.conf
# ... the packaged default options follow below ...

So you write one small file. Create /etc/ssh/sshd_config.d/10-hardening.conf with the settings that matter, and give it a number low enough to sort near the front. One habit to keep here: in this config, comments live on their own lines. sshd does not read a comment tacked onto the end of a setting, and a stray one after a value can make the whole file fail its syntax check.

/etc/ssh/sshd_config.d/10-hardening.conf
# Keys only. The single biggest change.
PasswordAuthentication no
PubkeyAuthentication yes
# Close the keyboard-interactive password path too, so no
# password prompt can slip back in through PAM.
KbdInteractiveAuthentication no
# Nobody logs in straight to root.
PermitRootLogin no
# Only members of this group may connect at all.
AllowGroups ssh-users
# Trim the attack surface and tighten the limits.
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 20

No root at the door, and a guest list

Two lines in that file carry most of the weight. PermitRootLogin no means nobody connects straight into root, the all-powerful administrator account. Instead everyone logs in as a named human, then reaches for admin rights one action at a time with sudo (superuser do, which runs a single command with elevated rights and records who ran it). The master key that opens every room still exists. You do not let anyone walk in the front door already holding it. People come in as themselves, and they check the master key out at the desk, where the desk writes down their name. That written record is your audit trail. A shared root login destroys it, because every action shows up as 'root' and you can never tell who did what.

AllowGroups ssh-users adds a guest list at the door. Even a valid account with a valid key is refused unless it belongs to the ssh-users group. Stack these together and an attacker now needs three separate things at once: the actual private key of a real user, that user's membership in ssh-users, and then a second secret, the sudo password, before they reach root. Take away any one and they stop at the step before. You create the group and add your user like this.

~/secopslog — bash
$ sudo groupadd ssh-users sudo usermod -aG ssh-users deploy id deploy
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),1005(ssh-users)

Apply it without locking yourself out

Editing this config is one of the few changes on a server that can shut you out of it for good. So apply it like someone who has been burned before. Validate the file first with sshd -t, which checks the syntax and prints nothing at all if it is happy. Then reload, which tells the running sshd to re-read its config without dropping the connections it already has, so your current session stays alive.

Keep your session open until the new one works
A single bad line plus a reload can refuse your next login with no way back in. Never reload sshd from a config you have not validated, and never close your working terminal until a brand-new connection has succeeded in a second one. That live session is your lifeline: if the change breaks login, it is how you undo it. On a cloud server, find the provider's web or serial console before you start, in case you lose SSH entirely.
~/secopslog — bash
$ sudo sshd -t && sudo systemctl reload ssh && echo reloaded
reloaded

Validation catches typos, but it does not tell you what sshd actually decided after reading every included file. For that, ask sshd to print its effective settings with sshd -T, which dumps the real values it will use, with the keyword names in lowercase. This is how you prove the drop-in file took effect instead of hoping it did.

~/secopslog — bash
$ sudo sshd -T | grep -Ei 'passwordauthentication|permitrootlogin|pubkeyauthentication|allowgroups'
permitrootlogin no pubkeyauthentication yes passwordauthentication no allowgroups ssh-users

Now look at the same log you started with. On the defender's side, a good change shows up as a shift in what the log records: your real user getting in by key, and the bots that used to guess passwords now bouncing off before authentication even begins.

~/secopslog — bash
$ sudo journalctl -u ssh --since "5 min ago" | tail -4
Jul 17 09:41:20 web01 sshd[22087]: Accepted publickey for deploy from 203.0.113.9 port 49556 ssh2: ED25519 SHA256:9lYQ8s0mR2c1fV7pKxg4dNbZ0tHhq3aWjLuE6oPQnRs Jul 17 09:41:20 web01 sshd[22087]: pam_unix(sshd:session): session opened for user deploy(uid=1000) by (uid=0) Jul 17 09:42:03 web01 sshd[22095]: Connection closed by authenticating user root 45.148.10.62 port 60122 [preauth] Jul 17 09:42:07 web01 sshd[22098]: Invalid user admin from 193.32.162.44 port 41230 [preauth]

The first two lines are you, in by key. The last two are bots hitting a wall. 'Connection closed ... [preauth]' means the connection was dropped before any authentication succeeded, and with passwords off there is no path for them past that point. The noise continues. The danger is gone.

What one incoming connection has to get past
1Reach port 22
any host on the internet can try
2Pass AllowGroups
user must be in ssh-users, or it is refused
3Prove a private key
passwords are off, so there is nothing to brute-force
4Land as a normal user
root login is off, so never straight to root
5sudo for root
a second secret, and the log records who used it
fail2ban is a speed bump, not the wall
fail2ban watches your logs and temporarily firewalls off any IP address that fails too many times. Against keys-only SSH it mostly earns you nothing. There are no passwords left to guess, so the bots it bans were already failing at a step that leads nowhere, and a botnet spread across thousands of addresses rotates to a fresh one the moment you ban an old one. Run fail2ban if you want to trim log noise. Do not mistake it for the thing keeping you safe. Disabling password authentication is that thing.
Quick check
01You disable password authentication, reload sshd, and confirm your key login works. The next day the auth log still shows thousands of failed 'root' and 'invalid user' connection attempts from new IP addresses every hour. What does that actually tell you?
Incorrect — the attempts are being refused at pre-auth. Run sshd -T to confirm passwordauthentication is no, but ongoing knocks are not evidence it is yes.
Correct — disabling passwords removes the win condition, not the traffic. The noise is expected and harmless.
Incorrect — with no password path, banning IPs only trims log noise. It is not what is protecting you.
Incorrect — keys cannot be brute-forced by connection attempts, so failed logins are not progress toward guessing a key.
02The shipped /etc/ssh/sshd_config has 'PasswordAuthentication yes' further down, while your drop-in file /etc/ssh/sshd_config.d/10-hardening.conf sets it to 'no'. Why does your 'no' win?
Incorrect — there is no special priority for the directory; what wins is read order, driven by where the Include line sits.
Correct — sshd honors the first value it meets, and the early Include pulls your file in ahead of the packaged defaults.
Incorrect — it is the opposite; for almost every setting sshd takes the first value, not the last.
Incorrect — the number only sorts drop-ins among themselves; the first-value rule is what makes the setting win overall.
03On a server where your public key is NOT yet installed, you set 'PasswordAuthentication no' and 'PermitRootLogin no', validate with sshd -t, and reload. Your current password-based session stays connected. What is the real danger?
Incorrect — with no key installed and passwords off, closing this session leaves no working way back in.
Incorrect — a reload re-reads the config without dropping existing connections, so this session survives.
Correct — the lesson's core rule is to get key login working first; disabling passwords before the key is in place strands you.
Incorrect — that config is valid syntax, and sshd -t checks form, not whether you left yourself a way in.

One last habit worth keeping. Run sshd -T | grep passwordauthentication on every server you manage, not one. A fleet is only as hard as its softest host, and the single machine that missed the drop-in file, the one still answering passwords, is exactly the one a bot will find first.

Try this

Work through “Apply it without locking yourself out” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: keep your session open until the new one works. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related