The order matters. Keys go in first, while password auth is still there to catch you if something goes wrong. The daemon config closes that door afterwards. Everything past that point — 2FA, host-based trust for automation, fail2ban and log monitoring — assumes the first two already work.
Never lock yourself out. Always test each change in a separate SSH session before closing your original connection.
Keys first#
Password authentication can be compromised through brute-force, keyloggers, or credential stuffing. Keys eliminate all of that.
Generate the pair on your local machine — not the server:
# ED25519 recommended
ssh-keygen -t ed25519 -C "your_email@example.com" -f ~/.ssh/id_prod_server
# RSA fallback for older systems
ssh-keygen -t rsa -b 4096 -C "your_email@example.com" -f ~/.ssh/id_prod_serverED25519 is faster, more secure, and uses shorter keys than RSA. I’ve switched all my infrastructure to it.
Deploy the public half:
ssh-copy-id -i ~/.ssh/id_prod_server.pub username@server_ip
# If ssh-copy-id isn't available
cat ~/.ssh/id_prod_server.pub | ssh username@server_ip "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"Open a second terminal and log in with the key, while the first session stays connected:
ssh -i ~/.ssh/id_prod_server username@server_ipA shell with no password prompt is the only acceptable result. Everything below assumes it.
If you’re still asked for a password, permissions are the usual cause — SSH ignores keys on files it considers too readable, and says nothing about why:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/id_prod_serverThe daemon config#
This is where the hardening actually happens. Rather than editing /etc/ssh/sshd_config itself, put your settings in a drop-in — /etc/ssh/sshd_config.d/10-hardening.conf. Debian 11+, Ubuntu 20.04+ and RHEL 9 all include that directory at the top of the main file (on RHEL 8, edit the main file instead), and for most keywords sshd uses the first value it reads, so the drop-in wins and package updates never have to merge your edits into theirs:
| |
Two notes on the cryptography. sntrup761x25519-sha512 is the post-quantum hybrid key exchange OpenSSH has defaulted to since 9.0, and a list without it quietly downgrades every connection to classical curve25519. From OpenSSH 9.9, put mlkem768x25519-sha256 first as well — it’s the default since 10.0. Older versions refuse to start on an algorithm name they don’t know, which sshd -t below catches.
The lines that will lock you out#
Two of them, if you paste the block unchanged.
AllowUsers deployer sysadmin is an allowlist, and everyone not on it is refused — including you. Put your own username there before saving.
Port 2222 only works if everything between you and the daemon agrees. On RHEL, SELinux won’t let sshd bind a port that isn’t labelled for it, and sshd fails to start. On Ubuntu 22.10 and newer, sshd is socket-activated and the port lives in ssh.socket. The firewall has to allow it, and so do three other places: the fail2ban jail, your ~/.ssh/config, and any monitoring that connects over SSH.
Three more are correct, but have consequences better met now than at 2am:
AllowTcpForwarding nokillsssh -L,ssh -DandProxyJumpthrough this host. If this box is your bastion, the answer isn’t to turn it back on globally — scope it with aMatch Userblock to the account that needs it.ClientAliveInterval 300withClientAliveCountMax 2drops connections whose client has stopped answering, after about ten minutes. It does not end idle sessions: a live client answers the keepalive however long it sits there.MaxSessions 2caps channels per connection, and automation tends to open more of them than you’d expect.
Two directives in that config are deliberately off now and come back on later. KbdInteractiveAuthentication no is correct until 2FA exists — turning it on before there’s a second factor just adds a prompt. HostbasedAuthentication no stays off unless you reach the machine-to-machine section; it’s a feature you enable on purpose, not a default worth having.
Validate, open the port, restart#
Check the syntax while you still have a session that works. sshd -t prints nothing when the config is valid:
sudo sshd -tThen, in this order: let the new port through, restart the daemon, and only after a new session has logged in on 2222, close 22.
sudo ufw allow 2222/tcp
# Ubuntu 22.10+ generates ssh.socket from sshd_config's Port
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket ssh
# after a new session on 2222 works:
sudo ufw delete allow 22/tcpsudo semanage port -a -t ssh_port_t -p tcp 2222
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --reload
sudo systemctl restart sshd
# after a new session on 2222 works:
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reloadKeep your current session open. Open a new terminal and test the connection before closing the original.
Two-factor for human accounts#
Even if someone steals your private key, 2FA means they still can’t get in without the second factor.
sudo apt install libpam-google-authenticatorsudo dnf install epel-release
sudo dnf install google-authenticatorRun the enrolment as the user who will actually log in — not under sudo, or the secret ends up in root’s home instead:
google-authenticatorPrompts to answer: time-based tokens → Yes, update ~/.google_authenticator → Yes, disallow multiple uses → Yes, increase time window → No (unless you have time sync issues), enable rate-limiting → Yes.
Scan the QR code with Google Authenticator, Authy, or any TOTP app.
It also prints emergency scratch codes, once. Save them somewhere that isn’t the phone you just enrolled. They are the only way back in if that phone is lost, wiped or reset, and they cannot be recovered afterwards.
Wire it into PAM, in /etc/pam.d/sshd. Two edits: add the TOTP module, and take out the password stack. The key is already the first factor — left in, the password stack makes every login ask for a password after the code, and fail outright for accounts that don’t have one.
# Debian / Ubuntu: comment out
# @include common-auth
# RHEL / Rocky: comment out
# auth substack password-auth
auth required pam_google_authenticator.so nulloknullok lets users without 2FA configured still log in. Remove it once all users have it set up — and verify they have, because removing it locks out every account that never ran the enrolment.
Then back to the drop-in, where keyboard-interactive now earns the yes it was denied earlier:
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactivesudo sshd -t && sudo systemctl restart ssh # sshd on RHELConnections now require both your SSH key and the 2FA code.
Every connection, which is the part that bites. scp, rsync, Ansible, backup jobs — anything non-interactive now stops at a prompt no script can answer. Scope 2FA to the accounts humans use and leave automation on keys alone: AuthenticationMethods publickey inside a Match User block for the automation accounts. Machine-to-machine trust is the next section’s job.
Host-based auth for machines#
Host-based authentication lets one server authenticate to another based on the client machine’s host key rather than user keys. I use it for automated backup systems, Ansible/Puppet, monitoring that executes remote commands, database replication, and CI/CD pipelines.
Only use this in controlled environments where you fully trust the client machines. It’s a complement to user key auth for specific automation use cases, not a replacement.
Prerequisites: Root access on both machines, DNS or /etc/hosts entries for hostname resolution.
On the server#
Enable it for the accounts that need it, reversing the HostbasedAuthentication no from earlier only inside a Match block — at the very end of the drop-in, since everything after a Match line belongs to it:
HostbasedUsesNameFromPacketOnly yes
HostbasedAcceptedAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
Match User backup,monitor,jenkins
HostbasedAuthentication yesIgnoreRhosts yes can stay: it covers the per-user ~/.rhosts and ~/.shosts files, while the system-wide /etc/ssh/shosts.equiv is read regardless. Set it to no only if you really want users to grant trust from their own home directories.
Then declare which hosts are trusted, in /etc/ssh/shosts.equiv:
# Format: hostname [username]
backup-server.example.com backup
monitoring.example.com monitor
ci-runner-01.example.com jenkinssudo chmod 600 /etc/ssh/shosts.equiv
sudo chown root:root /etc/ssh/shosts.equivOn the client#
Edit /etc/ssh/ssh_config:
HostbasedAuthentication yes
EnableSSHKeysign yes
PreferredAuthentications hostbased,publickeyssh-keysign must be setuid root to read the host keys:
sudo chmod 4711 /usr/lib/openssh/ssh-keysign # Debian / Ubuntu
sudo chmod 4711 /usr/libexec/openssh/ssh-keysign # RHEL / RockyExchange the host keys#
On the client, get the host public key:
sudo cat /etc/ssh/ssh_host_ed25519_key.pubAdd it to /etc/ssh/ssh_known_hosts on the server:
# Format: hostname,ip key-type public-key
backup-server.example.com,192.168.1.10 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILo...sudo chmod 644 /etc/ssh/ssh_known_hosts
sudo chown root:root /etc/ssh/ssh_known_hostsThen test. Authentication succeeded (hostbased) in the verbose output is what you’re looking for:
ssh -v backup@production-server.example.comBeyond two or three clients, collecting those keys by hand stops being reasonable. Save this on the server as /usr/local/bin/distribute-host-keys.sh:
| |
ssh-keyscan trusts whatever answers at that name. Run it from a network you trust, and compare the fingerprints against the clients themselves the first time.
sudo chmod +x /usr/local/bin/distribute-host-keys.sh
sudo /usr/local/bin/distribute-host-keys.shWhat it looks like in practice#
A backup server pulling from production. On the production servers, the Match User backup block above plus the trust line in shosts.equiv:
backup-server.example.com backupOn the backup server, in /etc/ssh/ssh_config:
Host prod-*
HostbasedAuthentication yes
PreferredAuthentications hostbased
User backupNow the backup server can pull automatically, with no key and no password anywhere in the job:
rsync -avz prod-web-01:/var/www/ /backup/web-01/Combining with user key auth is the safest configuration, because a compromised client host alone is then not enough:
AuthenticationMethods publickey,hostbasedReview /etc/ssh/shosts.equiv monthly, keep LogLevel VERBOSE so host-based authentications actually appear in the logs, and restrict SSH by firewall to the trusted client IPs.
Revoking access is two removals and a restart — miss either one and the trust survives:
# Remove the line from shosts.equiv
sudo sed -i '/^hostname\.example\.com /d' /etc/ssh/shosts.equiv
# Remove the host key
sudo ssh-keygen -R hostname.example.com -f /etc/ssh/ssh_known_hosts
sudo systemctl restart ssh # sshd on RHELWatching it#
Fail2ban monitors logs and blocks IPs that show malicious behavior.
sudo apt install fail2bansudo dnf install epel-release
sudo dnf install fail2banCreate /etc/fail2ban/jail.local. The port has to match the one in your sshd config, or the jail bans on a port nothing is listening on. backend = systemd reads sshd’s entries from the journal: Debian 12 and newer don’t write /var/log/auth.log unless rsyslog is installed, and a jail pointed at a missing file stops fail2ban from starting.
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
destemail = your_email@example.com
sendername = Fail2Ban
action = %(action_mwl)s
[sshd]
enabled = true
port = 2222
backend = systemdaction_mwl sends mail, so it needs a working MTA on the host; without one, use the default %(action_)s.
sudo systemctl enable --now fail2ban
# Status and management
sudo fail2ban-client status sshd
sudo fail2ban-client get sshd banned
sudo fail2ban-client set sshd unbanip 192.168.1.100Your own SSH config#
On your local machine, ~/.ssh/config saves typing the port and key on every connection:
Host production-server
HostName server_ip
Port 2222
User deployer
IdentityFile ~/.ssh/id_prod_server
ServerAliveInterval 60
ServerAliveCountMax 3
Host staging-server
HostName staging_ip
Port 2222
User deployer
IdentityFile ~/.ssh/id_staging_server
ProxyJump bastion-hostssh production-serverReading the logs#
Directly, when you want to know what happened. With password authentication off, failed attempts show up as invalid users and closed pre-auth connections, not as “Failed password”:
# Real-time
sudo journalctl -u ssh -f # -u sshd on RHEL
# Probing and failures
sudo journalctl -u ssh --since today | grep -E "Invalid user|Connection closed by authenticating user" | tail -20
# Successful logins
sudo journalctl -u ssh --since today | grep "Accepted publickey" | tail -20And a report worth having in your inbox rather than one you have to remember to run — /usr/local/bin/ssh-monitor.sh:
| |
sudo chmod +x /usr/local/bin/ssh-monitor.sh
# Daily at 09:00, by mail
echo "0 9 * * * root /usr/local/bin/ssh-monitor.sh | mail -s 'SSH Security Report' your_email@example.com" \
| sudo tee /etc/cron.d/ssh-monitorA file in /etc/cron.d, not crontab -: piping into crontab - replaces root’s entire crontab with that one line.
When it breaks#
Can’t connect at all after a config change. Check the daemon is running, the firewall allows the new port, and the config parses:
sudo systemctl status ssh # sshd on RHEL
sudo ufw status # or: firewall-cmd --list-all
sudo sshd -t
sudo journalctl -u ssh -n 50On RHEL, sshd failing to start after a port change with a bind error is SELinux: the semanage port line above is missing.
Permission denied (publickey). Almost always file permissions — the same ones from the first section. ~/.ssh must be 700, authorized_keys 600, private keys 600:
ls -la ~/.ssh
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keysToo many authentication failures. Your agent is offering every key it holds and the server cuts you off before it reaches the right one:
ssh-add -D
ssh-add ~/.ssh/id_prod_server
# Or force a specific key
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_prod_server user@serverThe 2FA code is rejected. TOTP is clock-based, so the server drifting is enough:
timedatectl status
sudo systemctl restart chrony # chronyd on RHELHost-based auth silently falls back to another method. The usual causes, in the order worth checking:
# Server logs
sudo journalctl -u ssh -n 50 | grep -i hostbased
# The client's name must match shosts.equiv
hostname -f
# ssh-keysign must be setuid root: -rws--x--x
ls -l /usr/lib/openssh/ssh-keysign
# The client's host key must be on the server
sudo grep "$(hostname -f)" /etc/ssh/ssh_known_hosts
# Full debug from the client
ssh -vvv -o PreferredAuthentications=hostbased user@serverKeeping it that way#
Once a month:
# Review authorized_keys
cat ~/.ssh/authorized_keys
# Host key types and sizes — no DSA, no RSA under 3072 bits
for key in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$key"; done
# Users with empty passwords
sudo awk -F: '($2 == "") {print $1}' /etc/shadowI rotate SSH keys annually: generate the new pair, deploy it everywhere, test, then remove the old public key. The removal is the step people skip, and a key you forgot to revoke is indistinguishable from one you never had.
The auth log that started this still fills up. The difference is that 50,000 attempts against key-only auth on a non-standard port is a graph, not an incident.