SSH Hardening

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
Setup Walkthrough · Generating and Deploying an SSH Key Pair
Generate a key on Windows, copy it to the Linux server, and confirm it works before touching the password settings.
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" # Default path: ~/.ssh/id_ed25519 (i.e. /home/philip/.ssh/id_ed25519)
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)
# Read your public key and append it to authorized_keys on the server 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.
# On the server — check permissions $ 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 ✓ # If permissions are wrong, fix them: $ 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.
# Open a brand new PowerShell or WSL window. Do not close the existing SSH 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 # If it still asks for a system password (not the key passphrase), key auth failed. # Debug with verbose output: PS> ssh -v -i $env:USERPROFILE\.ssh\id_ed25519 philip@webserver 2>&1 | Select-String "Auth" # Look for lines like: # "Trying public key..." → server tried the key # "Authentications that can continue: publickey" → server accepts keys # "Server accepts key" → your key was recognised
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 # No output = no errors. Any output here is a syntax problem. # Fix it before continuing — a broken config will prevent sshd from starting.
7
Reload sshd — keeping the existing session open as a safety net.
$ sudo systemctl reload sshd # reload applies the new config without disconnecting existing sessions. # Your current SSH session remains active. # Now immediately open another new terminal and test login:
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
# Enable the ssh-agent service (one-time setup, run as administrator) PS> Set-Service ssh-agent -StartupType Automatic PS> Start-Service ssh-agent # Add your key to the agent (prompts for passphrase once) 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) # Now ssh-ing to the server uses the key without asking for the passphrase PS> ssh philip@webserver philip@webserver:~$ ← no passphrase prompt # List keys currently in the agent PS> ssh-add -l 256 SHA256:AbCdEf... philip-laptop (ED25519)
WSL / Linux — agent starts per session
# Start the agent and add the key $ eval "$(ssh-agent -s)" Agent pid 12345 $ ssh-add ~/.ssh/id_ed25519 Enter passphrase: ●●●●●●●● Identity added: /home/philip/.ssh/id_ed25519 # Add to ~/.bashrc or ~/.zshrc to auto-start the agent and load the key: # eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_ed25519 2>/dev/null

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.

# On the server — authorized_keys with multiple keys (one per line) $ cat ~/.ssh/authorized_keys ssh-ed25519 AAAA...abc philip-laptop ssh-ed25519 AAAA...def philip-desktop ssh-ed25519 AAAA...ghi philip-work-laptop # To revoke access from a specific machine: open authorized_keys # and delete the relevant line. Reload sshd (not required — authorized_keys # is read on each connection attempt). # To add a new machine: generate a key on the new machine, then # append its public key to authorized_keys on the server: $ echo "ssh-ed25519 AAAA...xyz philip-new-machine" >> ~/.ssh/authorized_keys $ chmod 600 ~/.ssh/authorized_keys # permissions not changed by echo, but good habit

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
# ~/.ssh/config — SSH client configuration Host webserver HostName 192.168.1.100 # or ssh.osztromok.com for external access User philip IdentityFile ~/.ssh/id_ed25519 Port 22 ServerAliveInterval 60 # External access via the DDNS hostname Host webserver-ext HostName ssh.osztromok.com User philip IdentityFile ~/.ssh/id_ed25519 Port 22
# After saving config, connect with just the alias PS> ssh webserver # local connection PS> ssh webserver-ext # external via DDNS # The config file should not be world-readable $ chmod 600 ~/.ssh/config # on WSL/Linux # On Windows, the permissions are managed by the file system — keep it in your user profile folder

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.
# Recovery — if you have physical access to the server # 1. Log in locally (console access) # 2. Temporarily re-enable password auth: $ sudo nano /etc/ssh/sshd_config.d/hardening.conf # Change: PasswordAuthentication no → PasswordAuthentication yes $ sudo systemctl reload sshd # 3. SSH in with your password from another machine # 4. Generate a new key pair on the client, copy the new public key to authorized_keys # 5. Re-disable password auth in the config $ sudo systemctl reload sshd # Prevention: keep an encrypted backup of the private key # Options: password manager (Bitwarden, 1Password), encrypted USB drive, # or print the key and store it physically (it's just text)
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.

# In /etc/fail2ban/jail.local — tighten the SSH jail now that passwords are off [sshd] enabled = true port = ssh logpath = %(sshd_log)s backend = %(sshd_backend)s maxretry = 3 # reduced from 5 — legitimate key auth doesn't fail 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 / FilePurpose
ssh-keygen -t ed25519 -C "label"Generate an Ed25519 key pair — run on client machine
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@serverCopy 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@webserverVerbose login — shows which auth methods are tried and why they fail
sudo sshd -tValidate sshd_config syntax — run before every reload
sudo systemctl reload sshdApply new config without disconnecting existing sessions
/etc/ssh/sshd_config.d/hardening.confDrop-in file for hardening settings — upgrade-safe location
~/.ssh/authorized_keysHolds allowed public keys (one per line) — permissions must be 600
~/.ssh/configClient-side aliases: Host, HostName, User, IdentityFile, Port
ssh-add ~/.ssh/id_ed25519Add key to SSH agent — passphrase once per session
ssh-add -lList keys currently loaded in the agent
sshd_config settingRecommendedEffect
PermitRootLoginnoBlock all direct root SSH login
PasswordAuthenticationnoForce key-only auth — eliminates brute-force attack surface
PubkeyAuthenticationyesExplicitly enable key auth
AllowUsersphilipWhitelist — only named accounts can SSH regardless of key
MaxAuthTries3Disconnect after 3 failed attempts per connection
LoginGraceTime30Close unauthenticated connections after 30 seconds
X11ForwardingnoNo graphical forwarding — server doesn't need it
ClientAliveInterval300Keepalive every 5 minutes
ClientAliveCountMax2Disconnect after 10 minutes of silence