Chapter 5 — SSH Hardening
UFW rate-limits SSH. fail2ban bans IPs after repeated failures. Both are important — but they're protecting against attacks on a password. The cleanest solution is to eliminate the password entirely and switch to SSH key authentication. With keys in place, brute-force becomes physically impossible: there's nothing to guess. After that, a handful of sshd_config changes close the remaining gaps.
What this chapter covers: Why SSH keys are stronger than passwords and how the authentication works. Generating an Ed25519 key pair from Windows (PowerShell and WSL). Copying the public key to the server. Testing key auth — and the critical rule of testing before disabling passwords. Hardening sshd_config via a drop-in file. Validating the config with sshd -t before reloading. SSH agent for passphrase management. Multiple machines and multiple keys. The ~/.ssh/config shortcut file. What to do if you lose your private key. Tightening fail2ban now that passwords are gone.
Why SSH Keys Beat Passwords
Password authentication — the attacker's job:
Attacker tries: "admin123" → wrong
Attacker tries: "password" → wrong
Attacker tries: "philip2026" → wrong
...
Attacker tries: "P@ssw0rd!" → MATCH (if your password is guessable)
With 5 tries/30s limit: slow, but not impossible. A strong random
password takes years. A weak one can fall in hours.
SSH key authentication — no password exists to guess:
Attacker tries to guess the private key → 2^256 possibilities (Ed25519)
At 1 trillion guesses/second: would take longer than the age of the universe.
There is no feasible brute-force attack against a properly generated key.
SSH keys work by mathematics, not secrets transmitted over the network. You hold a private key (never shared). The server holds your public key (can be posted publicly — it's useless without the matching private key). During login, the server challenges you to prove possession of the private key by signing a random message. The signature is verified against the public key. No password is transmitted. No secret crosses the wire.
- Unguessable: a 256-bit Ed25519 key has more possible values than atoms in the observable universe.
- Nothing to steal remotely: phishing for a password that doesn't exist doesn't work.
- Passphrase optional: you can add a passphrase to the private key for a second layer — even if someone copies your key file, they still need the passphrase to use it.
- Per-device keys: each machine you connect from gets its own key pair. Revoking access from one device (lost laptop) means removing one public key from the server — no password change needed on every device.
Choosing a Key Type
Ed25519
Recommended
Modern elliptic-curve algorithm. Fast, small key files, high security. Supported by all modern SSH clients and servers. Use this unless you need to connect to very old systems.
RSA 4096
Acceptable
The classic choice. 4096-bit RSA is still secure, but keys are larger and operations slower than Ed25519. Use only if you need compatibility with older OpenSSH (< 6.5).
RSA 2048
Avoid
Was the standard a decade ago. Now considered marginal. If you have existing 2048-bit keys, they're probably fine for now — but generate Ed25519 for new setups.
DSA / ECDSA
Avoid
DSA is disabled in modern OpenSSH. ECDSA can be weak if the random number generator is compromised. Neither is recommended for new keys.
Step-by-Step SSH Hardening
Safety order — do not skip or reorder these steps
Step 1: Generate a key pair on your Windows machine
Step 2: Copy the public key to the server
Step 3: Test key login in a
new terminal — keep the original session open
Step 4: Only after confirming key login works — edit sshd_config to disable passwords
Step 5: Validate config syntax with
sudo sshd -t
Step 6: Reload sshd — and immediately test login in another terminal
1
Generate the key pair on your Windows machine. Open PowerShell (or WSL — both work).
PowerShell
PS> ssh-keygen -t ed25519 -C "philip-laptop"
Generating public/private ed25519 key pair.
Enter file in which to save the key (C:\Users\philip/.ssh/id_ed25519):
↵ press Enter to accept the default path
Enter passphrase (empty for no passphrase): choose a strong passphrase
Enter same passphrase again: repeat it
Your identification has been saved in C:\Users\philip/.ssh/id_ed25519
Your public key has been saved in C:\Users\philip/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:AbCdEfGhIjKlMnOpQrStUvWxYz1234567890ab philip-laptop
WSL / Linux client
$ ssh-keygen -t ed25519 -C "philip-laptop"
The
-C comment identifies which machine this key came from — helpful when you have multiple keys in
authorized_keys. Use the passphrase: if the private key file is ever stolen, the passphrase prevents the thief from using it.
2
Copy the public key to the server. Two methods depending on your environment.
PowerShell (no ssh-copy-id — do it manually)
PS> type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh philip@webserver "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
philip@webserver's password: (enter your password — last time you'll need to)
WSL / Linux / macOS (ssh-copy-id available)
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub philip@webserver
philip@webserver's password: (enter your password)
Number of key(s) added: 1
Now try logging into the machine, with: "ssh 'philip@webserver'"
Both methods do the same thing: append your public key to
~/.ssh/authorized_keys on the server, creating the file and setting permissions if needed.
3
Verify the permissions on the server. Wrong permissions are the most common reason key auth silently fails.
$ ls -la ~/.ssh/
drwx------ 2 philip philip 4096 Jun 15 10:01 . ← 700 ✓
-rw------- 1 philip philip 574 Jun 15 10:01 authorized_keys ← 600 ✓
$ chmod 700 ~/.ssh
$ chmod 600 ~/.ssh/authorized_keys
$ chown -R philip:philip ~/.ssh
4
Test key login in a NEW terminal — do not close the original session.
PS> ssh -i $env:USERPROFILE\.ssh\id_ed25519 philip@webserver
Enter passphrase for key '/home/philip/.ssh/id_ed25519': (your key passphrase)
philip@webserver:~$ ← successful key-based login
PS> ssh -v -i $env:USERPROFILE\.ssh\id_ed25519 philip@webserver 2>&1 | Select-String "Auth"
Only proceed to Step 5 once this login works. If it fails, diagnose before touching any config.
5
Create the sshd hardening drop-in file. Using a drop-in file in
/etc/ssh/sshd_config.d/ is cleaner than editing
sshd_config directly — it survives package upgrades and is easy to review later.
$ sudo nano /etc/ssh/sshd_config.d/hardening.conf
Write the following content (explained in the next section):
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
X11Forwarding no
AllowUsers philip
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
6
Validate the config before restarting anything.
$ sudo sshd -t
7
Reload sshd — keeping the existing session open as a safety net.
$ sudo systemctl reload sshd
In a third terminal window:
ssh philip@webserver — it should connect using your key. If it connects, you're done. If it asks for a password, check the drop-in file for typos and re-check the
sshd -t validation.
With PasswordAuthentication no, anyone trying to SSH with a password gets "Permission denied (publickey)" immediately — no password prompt, no brute-force opportunity. The fail2ban jail still runs but will rarely trigger since there are no passwords to fail.
Understanding the sshd_config Settings
PermitRootLogin
no
Prevents anyone logging in directly as root. The root account is a constant brute-force target. Admin access happens via a normal user + sudo. Even if root has no password, this blocks all root login attempts.
PasswordAuthentication
no
The main hardening step. Disables password-based SSH login entirely. Only key authentication works. Brute-force attacks become futile.
PubkeyAuthentication
yes
Explicitly enables key-based auth. This is the default, but stating it clearly makes the intent obvious and prevents future confusion.
PermitEmptyPasswords
no
Blocks login to accounts that have no password set (an empty password string). Should always be no.
ChallengeResponseAuthentication
no
Disables keyboard-interactive authentication (a PAM-backed mechanism that can allow passwords even when PasswordAuthentication is no on some configurations). Disable it explicitly.
KbdInteractiveAuthentication
no
Same as ChallengeResponseAuthentication on newer OpenSSH versions (the name changed). Include both for compatibility.
X11Forwarding
no
Disables X11 (graphical desktop) forwarding over SSH. A headless server doesn't need this, and it's a historical attack surface.
AllowUsers
philip
Whitelist of usernames allowed to SSH in. Any user not listed is denied — even if they have a valid key. Prevents attackers from accessing service accounts (www-data, daemon) via SSH even if they somehow get a key onto the server.
MaxAuthTries
3
Limits the number of authentication attempts per connection to 3. After 3 failures, the connection is dropped. This is per-connection, not per-IP — fail2ban handles the IP-level banning across connections.
LoginGraceTime
30
How long (in seconds) sshd waits for a successful login before closing the unauthenticated connection. Default is 120 seconds — 30 is plenty for a legitimate key auth.
ClientAliveInterval
300
Send a keepalive to the client every 300 seconds (5 minutes) of idle. Prevents stale connections from accumulating and consuming server resources.
ClientAliveCountMax
2
After 2 missed keepalive responses (2 × 300s = 10 minutes unresponsive), disconnect the session. Keeps the connection list clean.
If you have multiple users on the server, add them all to AllowUsers: AllowUsers philip alice (space-separated). If you forget a user, they'll be locked out of SSH even with a valid key — which you'll discover at the worst possible time.
SSH File Permissions — Why They Matter
OpenSSH refuses to use keys if the files or directories have permissions that are too open. This is a security feature, not a bug — a world-readable private key file is a security problem. If key auth silently fails, wrong permissions are the first thing to check.
~/.ssh/
chmod 700
Only the owner can read, write, or list this directory. Group and others: no access.
~/.ssh/authorized_keys
chmod 600
Only the owner can read and write. Group and others: no access.
~/.ssh/id_ed25519
chmod 600
Private key — only the owner can read it. If anyone else can read it, the key is compromised.
~/.ssh/id_ed25519.pub
chmod 644
Public key — readable by anyone (it's public). 644 is fine; it contains no secrets.
SSH Agent — Passphrase Once Per Session
If you added a passphrase to your private key (you should have), you're prompted for it every time you use the key. The SSH agent solves this: it holds the decrypted key in memory for your session, so you only type the passphrase once.
Windows — ssh-agent is built in
PS> Set-Service ssh-agent -StartupType Automatic
PS> Start-Service ssh-agent
PS> ssh-add $env:USERPROFILE\.ssh\id_ed25519
Enter passphrase for C:\Users\philip/.ssh/id_ed25519: ●●●●●●●●
Identity added: C:\Users\philip/.ssh/id_ed25519 (philip-laptop)
PS> ssh philip@webserver
philip@webserver:~$ ← no passphrase prompt
PS> ssh-add -l
256 SHA256:AbCdEf... philip-laptop (ED25519)
WSL / Linux — agent starts per session
$ eval "$(ssh-agent -s)"
Agent pid 12345
$ ssh-add ~/.ssh/id_ed25519
Enter passphrase: ●●●●●●●●
Identity added: /home/philip/.ssh/id_ed25519
Multiple Machines and Multiple Keys
Each machine you connect from should have its own key pair — never copy private keys between machines. If a laptop is lost, you remove that machine's public key from authorized_keys on the server without affecting any other machine.
$ cat ~/.ssh/authorized_keys
ssh-ed25519 AAAA...abc philip-laptop
ssh-ed25519 AAAA...def philip-desktop
ssh-ed25519 AAAA...ghi philip-work-laptop
$ echo "ssh-ed25519 AAAA...xyz philip-new-machine" >> ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/authorized_keys
The ~/.ssh/config Shortcut File
Instead of typing ssh -i ~/.ssh/id_ed25519 philip@192.168.1.100 -p 22 every time, define a host alias in ~/.ssh/config (on your Windows machine or WSL client).
Windows path: C:\Users\philip\.ssh\config
Linux / WSL path: ~/.ssh/config
Host webserver
HostName 192.168.1.100
User philip
IdentityFile ~/.ssh/id_ed25519
Port 22
ServerAliveInterval 60
Host webserver-ext
HostName ssh.osztromok.com
User philip
IdentityFile ~/.ssh/id_ed25519
Port 22
PS> ssh webserver
PS> ssh webserver-ext
$ chmod 600 ~/.ssh/config
What to Do If You Lose Your Private Key
If your private key is lost and you have no other keys in authorized_keys, and PasswordAuthentication is set to no — you are locked out of SSH. Recovery requires physical access to the server (keyboard + monitor). This is why it's important to keep a backup of the private key (encrypted, in a secure location like a password manager) and to set up at least two machines (or a backup key) before disabling password auth.
$ sudo nano /etc/ssh/sshd_config.d/hardening.conf
$ sudo systemctl reload sshd
$ sudo systemctl reload sshd
Best practice: two independent keys. Before hardening, add a key from a second machine (or a backup key stored securely). If you ever lose the primary key, the second one gets you back in without physical access to the server.
Updating fail2ban After Disabling Passwords
With PasswordAuthentication no, the only SSH authentication failures that can happen are: wrong key (rare — usually misconfiguration), unsupported key type, or connecting from a machine with no key at all. With no passwords to brute-force, the SSH jail's job is lighter — but keep it running, since any failure is now more suspicious.
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 3
bantime = 24h
$ sudo fail2ban-client reload sshd
Check your own IP is in ignoreip before reducing maxretry. If your SSH client has connection issues (wrong key path, agent not running) and you try several times quickly, you could ban yourself with maxretry=3.
Troubleshooting
Permission denied (publickey) — key auth failing
In order of likelihood: (1) Wrong permissions on ~/.ssh (needs 700) or ~/.ssh/authorized_keys (needs 600) on the server. Fix: chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys. (2) Public key not actually in authorized_keys — check: cat ~/.ssh/authorized_keys on the server; it should contain your public key. (3) Wrong user — the authorized_keys file must be in the home directory of the user you're logging in as. (4) SELinux context wrong (rare on Ubuntu) — restorecon -Rv ~/.ssh. (5) Verbose debug from client: ssh -vvv philip@webserver — look for "Offering public key" then "Server accepts key" or the rejection reason.
sshd won't start after editing sshd_config / drop-in file
Config syntax error. Check: sudo sshd -t — it prints the exact line and problem. Fix the issue, validate again, then reload. If you reloaded before checking and sshd is now down: you can still fix the config via the console. sudo systemctl status sshd will show the error. This is why the reload command is safer than restart — existing connections stay alive even if the reload fails.
sshd keeps asking for password even after PasswordAuthentication no
Another config file is overriding your setting. On Ubuntu, /etc/ssh/sshd_config may have Include /etc/ssh/sshd_config.d/*.conf — check that the drop-in file is in the right directory and has a .conf extension. Also check: sudo grep -r PasswordAuthentication /etc/ssh/ — if there are conflicting settings across multiple files, the last one wins (files are processed alphabetically in sshd_config.d/). Name your file 99-hardening.conf to ensure it's last.
AllowUsers locked out my other user
AllowUsers philip — only philip can SSH. Any other user is denied even with a valid key. Fix: add missing users: AllowUsers philip alice. If you're locked out, recover via console access or from an existing open SSH session. Reload after fixing: sudo systemctl reload sshd.
ssh-add says "Could not open a connection to your authentication agent"
The SSH agent isn't running in the current shell. On Linux/WSL: run eval "$(ssh-agent -s)" then ssh-add ~/.ssh/id_ed25519. On Windows: ensure the ssh-agent service is running — open PowerShell as admin and run Start-Service ssh-agent. Check current status: Get-Service ssh-agent.
Connection times out immediately after hardening
Check UFW — did the port get blocked? sudo ufw status. Also check fail2ban: sudo fail2ban-client status sshd — your IP may have been banned if you had several failed attempts during setup. Unban with: sudo fail2ban-client set sshd unbanip YOUR.IP. If the connection times out (no response at all), it's UFW or a network issue. If you get "Connection refused", sshd isn't running.
Quick Reference — Chapter 5
| Command / File | Purpose |
| ssh-keygen -t ed25519 -C "label" | Generate an Ed25519 key pair — run on client machine |
| ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server | Copy public key to server (WSL/Linux clients) |
| type pub.key | ssh user@server "cat >> ~/.ssh/authorized_keys" | Copy public key manually (Windows PowerShell) |
| ssh -v philip@webserver | Verbose login — shows which auth methods are tried and why they fail |
| sudo sshd -t | Validate sshd_config syntax — run before every reload |
| sudo systemctl reload sshd | Apply new config without disconnecting existing sessions |
| /etc/ssh/sshd_config.d/hardening.conf | Drop-in file for hardening settings — upgrade-safe location |
| ~/.ssh/authorized_keys | Holds allowed public keys (one per line) — permissions must be 600 |
| ~/.ssh/config | Client-side aliases: Host, HostName, User, IdentityFile, Port |
| ssh-add ~/.ssh/id_ed25519 | Add key to SSH agent — passphrase once per session |
| ssh-add -l | List keys currently loaded in the agent |
| sshd_config setting | Recommended | Effect |
| PermitRootLogin | no | Block all direct root SSH login |
| PasswordAuthentication | no | Force key-only auth — eliminates brute-force attack surface |
| PubkeyAuthentication | yes | Explicitly enable key auth |
| AllowUsers | philip | Whitelist — only named accounts can SSH regardless of key |
| MaxAuthTries | 3 | Disconnect after 3 failed attempts per connection |
| LoginGraceTime | 30 | Close unauthenticated connections after 30 seconds |
| X11Forwarding | no | No graphical forwarding — server doesn't need it |
| ClientAliveInterval | 300 | Keepalive every 5 minutes |
| ClientAliveCountMax | 2 | Disconnect after 10 minutes of silence |