Protect the private key (measured KDF rounds), restrict keys, run an SSH CA with revocation, audit algorithms, fix the first-match-wins client trap, and change SSH without a lockout
SSH hardening in depth: keys, algorithms, certificates, forwarding and safe changes
sudo sshd -t && sudo systemctl reload ssh
SSH is the front door of almost every Linux server, so it is the first thing attackers knock on and the last thing you can afford to lock yourself out of. The Linux Hardening Basics page already gives the short list of sshd_config settings, and the two key guides show how to create keys and copy them to servers. This page goes further: how to protect the private key itself, how to limit what a stolen key can do, how SSH certificates replace piles of authorized_keys files, which algorithms to keep and which to remove, how forwarding and the agent can hand your keys to an attacker, how to keep the client side tidy, and how to change all of it without locking yourself out.
Important note on what was tested. The lab machine has the OpenSSH CLIENT tools (OpenSSH_9.6p1 Ubuntu-3ubuntu13.19, Ubuntu 24.04) but not the server, sshd, and installing or running a server was out of scope. So everything that the client tools can show was run for real, and the output is real: ssh -Q, ssh -G, ssh-keygen (keys, fingerprints, passphrases, certificates, revocation, known_hosts), ssh-agent and ssh-add. Everything that only a running sshd can show (the sshd_config directives, what a restricted key refuses to do, log lines, what a client sees when the server rejects it) is described from the OpenSSH documentation and written without output, marked "not run". Test every server change on a spare machine first, and always check it with sshd -t and sshd -T. Names of users and hosts are examples.
1. What you are defending against, and the changes that matter most
Think about how SSH is actually broken into, because it decides what is worth your time:
Guessed or reused passwords automated scans try common passwords against every address on the internet, all day
A stolen private key malware, a lost laptop, a key committed to git, a backup on the wrong disk
A key that outlived its owner a leaver's key still in authorized_keys on forty servers
A man in the middle on first use the first connection to a new host trusts whatever key it sees
A vulnerable sshd rare, but severe: patch quickly (and see the exposure section)
Forwarding abuse an agent socket or tunnel on a compromised jump host reaches everything you can reach
In order of value, the changes that matter most:
1. Keys instead of passwords, and passwords switched off (section 3)
2. A passphrase on every private key, with strong KDF rounds, or a hardware key (section 4)
3. No root login, and only named users or groups may log in (section 3)
4. Keep OpenSSH patched, and keep the defaults of a current version rather than old hand-made algorithm lists (section 7)
5. Do not reach SSH from the whole internet when you do not have to (section 11)
6. Limit what each key may do, and remove leavers' keys: or use certificates that expire (sections 5 and 6)
7. Do not forward the agent; use ProxyJump (section 10)
8. Log at VERBOSE and look at the logs (section 12)
Everything else is detail. Do the first three today.
2. Change SSH without locking yourself out
Almost everyone has locked themselves out of a server once. The rules that prevent it (the Hardening Basics page lists the first five; here is the full routine). All commands in this section need a server and were not run in the lab:
Have a second way in that does not depend on SSH: the cloud console, the hypervisor or IPMI console, a rescue mode. Test that you can reach it before you start.
Keep your current SSH session open and make the change from it. Test the new settings from a SECOND session, and only then close the first.
Validate before reloading: sudo sshd -t prints nothing when the syntax is fine. Reload (reload keeps existing sessions); do not restart.
See the settings sshd will REALLY use, after all the include files and Match blocks: sudo sshd -T | less. Because the first value found for a keyword wins, a file that sorts earlier (a cloud-init drop-in) can silently beat yours.
Test a Match block for a specific connection without logging in: sudo sshd -T -C user=alice,host=web1,addr=203.0.113.5 | grep -i -E 'allowtcpforwarding|authenticationmethods'
Put a timer on the risk: schedule an automatic rollback before you apply a risky change, and cancel it when you have proved the new login works:
sudo cp /etc/ssh/sshd_config.d/00-hardening.conf /root/00-hardening.conf.new
echo "cp /root/00-hardening.conf.good /etc/ssh/sshd_config.d/00-hardening.conf && systemctl reload ssh" | sudo at now + 10 minutes
(apply the change, test it from a new session; if it works: sudo atq, then sudo atrm JOB)
Keep a break-glass key (a separate key stored offline) in authorized_keys of an emergency account, and test it every few months.
Keep the configuration in version control (etckeeper or git), so the last good state is one command away.
The same care applies to the client side: when you change your own ~/.ssh/config, check with ssh -G before you rely on it (section 9).
3. Authentication: keys only, fewer tries, MFA
A hardened server accepts public-key logins for named users and nothing else. This is the core of the drop-in file /etc/ssh/sshd_config.d/00-hardening.conf (not run in the lab; the complete file is in section 14):
PermitRootLogin no log in as yourself, then use sudo (keeps an audit trail of who did what)
PasswordAuthentication no only AFTER you have proven key login works from a second session
KbdInteractiveAuthentication no also closes the "password through PAM" side door
PubkeyAuthentication yes
AuthenticationMethods publickey the only method allowed; for multi-factor see below
PermitEmptyPasswords no
MaxAuthTries 3 default is 6; counts every key the client offers, not only passwords
LoginGraceTime 20 default is 120 seconds to complete the login
MaxStartups 10:30:60 start dropping unauthenticated connections at random from 10, probability 30 percent, up to a hard limit of 60 (default 10:30:100)
MaxSessions 4 sessions per connection
AllowGroups sshusers only members of this group can log in (put yourself in it first)
StrictModes yes refuse keys when ~ or ~/.ssh or authorized_keys are writable by others
Notes: MaxAuthTries counts keys, so a client that offers six keys before the right one can be refused for "too many authentication failures" even though no password was tried (section 9 shows the cause: IdentitiesOnly). AllowGroups and AllowUsers are allow-lists; DenyUsers and DenyGroups are deny-lists, checked first. AllowUsers also takes patterns with an address: AllowUsers deploy@192.0.2.0/24 admin.
Prove the password path is closed from a second machine (not run): ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no alice@server must end with "Permission denied (publickey)", without a password prompt.
Multi-factor: require a key AND a second factor. With PAM and a TOTP module (libpam-google-authenticator), set AuthenticationMethods publickey,keyboard-interactive, KbdInteractiveAuthentication yes, and add the module to /etc/pam.d/sshd. A cleaner second factor is a hardware key: an ed25519-sk key needs a physical touch, and PubkeyAuthOptions verify-required on the server also demands a PIN (OpenSSH 8.4 and newer). Test it on a spare server before you apply it to anything important, because a mistake locks everyone out.
Passwords for sudo are a separate matter: see the Users, Groups and sudo page.
4. Protect the private key: type, size, passphrase and KDF rounds
Types and sizes. Everything below was run for real. ed25519 is the right default: small, fast and robust. ecdsa is accepted but uses NIST curves that many people avoid. RSA needs at least 3072 bits for new keys.
$ ssh-keygen -t ed25519 -f k_ed25519 -C lab@example ; ssh-keygen -t ecdsa -b 256 ... ; ssh-keygen -t rsa -b 3072 ...
$ for k in k_ed25519 k_ecdsa k_rsa3072; do ssh-keygen -l -f $k.pub; done
256 SHA256:mAqR26dYZ8oL7Or4HlOdmNse7DGUJmvFWbrMrM6cTow lab@example (ED25519)
256 SHA256:R2eGVhsCNIhvMZsTFy+rM7ZlNnrnB68suxYalggus78 lab@example (ECDSA)
3072 SHA256:VSaAOSyi7zSfgW6vcokxDA9Gz6d8/+HLe82M8+/RYtU lab@example (RSA)
$ ls -l k_ed25519 k_ed25519.pub | awk '{print $1, $5, $9}'
-rw------- 399 k_ed25519
-rw-r--r-- 93 k_ed25519.pub
The private key has mode 600 and the public key 644. The line starting with the type, the base64 text and the comment, is the public key you copy to servers; the SHA256 value is the fingerprint, used to recognise a key. ssh-keygen -lv adds the "randomart" picture, which people use to compare two fingerprints by eye:
$ ssh-keygen -lv -f k_ed25519.pub
256 SHA256:mAqR26dYZ8oL7Or4HlOdmNse7DGUJmvFWbrMrM6cTow lab@example (ED25519)
+--[ED25519 256]--+
| |
| . |
| o . |
| + = O |
| o B ^ S |
| . O ^ . |
| E @ @ |
|.. O * + |
|+=+oO o |
+----[SHA256]-----+
A weak key is not refused at creation. This surprised even the author: this OpenSSH still created a DSA key and a 1024-bit RSA key without a word of complaint:
$ ssh-keygen -t dsa -f x_dsa -N ''
Generating public/private dsa key pair.
Your identification has been saved in x_dsa
$ ssh-keygen -t rsa -b 1024 -N '' -f x_rsa1024
Generating public/private rsa key pair.
Your identification has been saved in x_rsa1024
Creating a key and a server accepting it are different things: current servers refuse DSA, and the OpenSSH setting RequiredRSASize 3072 (version 9.1 and newer; the client parser accepts it, checked with ssh -G) makes ssh and sshd refuse RSA keys below that size. Set it on the server (not run) so a weak key cannot be used even if someone adds it. Plain "ssh-keygen" with no -t makes an ed25519 key on this version:
$ ssh-keygen -q -N '' -f x_default ; ssh-keygen -l -f x_default.pub
256 SHA256:6EbbLqxq/AQdP76jdk2xAldJS2Phk5VcfN0RJ7dSSOY user@host (ED25519)
Permissions are enforced. A private key readable by others is refused outright:
$ chmod 644 k_open; ssh-keygen -y -f k_open
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions 0644 for 'k_open' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
Fix with chmod 600 (and chmod 700 for ~/.ssh). On the server, StrictModes does the same for the home folder, ~/.ssh and authorized_keys.
Passphrase and KDF rounds. A private key without a passphrase is a password written in a file: anyone who copies the file can use it. With a passphrase, the key file is encrypted (the file stores the cipher and the KDF, both readable, and the encrypted key):
$ sed '1d;$d' kdf_100 | tr -d '\n' | base64 -d | strings -n 6 | head -4
openssh-key-v1
aes256-ctr
bcrypt
ssh-ed25519
The key is protected by aes256-ctr, with the key derived from your passphrase by bcrypt. An attacker who steals the file can guess passphrases offline, as fast as their hardware allows, so what matters is how slow each guess is. The -a option sets the number of bcrypt rounds. Measured on the lab machine (best of three, a wrong passphrase, time for one guess):
$ for a in 16 100 300; do ... ssh-keygen -y -P 'wrong guess' -f kdf_$a ...; done
rounds 16 115 ms per guess
rounds 100 703 ms per guess
rounds 300 2083 ms per guess
$ ssh-keygen -y -P 'wrong guess' -f kdf_100
Load key "kdf_100": incorrect passphrase supplied to decrypt private key
$ s=...; ssh-keygen -y -P 'wrong guess' -f kdf_upgrade ... (kdf_upgrade started at 16 rounds, then: ssh-keygen -p -a 200)
after -a 200: 1389 ms per guess
$ ssh-keygen -y -f k_ed25519 (no passphrase)
no passphrase: 7 ms (nothing to guess)
The default of 16 rounds costs an attacker about a tenth of a second per guess on this machine; 100 rounds is six times slower and 300 rounds eighteen times slower. A GPU farm is faster than this laptop, so treat the ratios as the lesson, not the absolute numbers. The cost to you is the same delay, once, each time you unlock the key (and once only per session with an agent). Use a long passphrase (four or five random words) together with -a 100 or more: ssh-keygen -t ed25519 -a 100. To raise the rounds of an old key without making a new one: ssh-keygen -p -a 200 -f ~/.ssh/id_ed25519 (it asks for the old passphrase and the new one; the public key and the fingerprint do not change, so nothing on the servers needs updating).
Better still, a key that cannot be copied: a FIDO2 hardware key (ssh-keygen -t ed25519-sk, not run here; it needs the device). The private half never leaves the token and each use needs a touch. Add -O resident to store it on the token, and -O verify-required to demand the PIN.
Housekeeping that matters: one key per person and per purpose (a laptop key, a CI deploy key, a break-glass key), never share a private key between people or machines, never put one in a chat, ticket or git repository (git secret scanners exist: use them), back up keys only in an encrypted form, rotate keys on a schedule and when a laptop is lost, and remove the old public key from every server (section 6 shows how certificates remove that chore).
5. Limit what a key can do: authorized_keys options
A line in ~/.ssh/authorized_keys can start with options that restrict the key. The key still proves who you are, but it can only do what the options allow. This is the cheapest way to turn "a key that is as powerful as an admin" into "a key that can only run one script". Here is a file with two entries (the first one for a CI system, the second for a person). The lab could check that ssh-keygen reads such a file; what the server DOES with the options needs a running sshd and was not run:
$ cat authorized_keys
# deploy key: may only run the deploy script, from the CI network, no tty, expires end of year
restrict,command="/usr/local/bin/deploy.sh",from="203.0.113.0/24",expiry-time="20261231" ssh-ed25519 AAAA... ci-deploy@example
# admin key: full shell but only from the office
from="192.0.2.0/24,!192.0.2.99" ecdsa-sha2-nistp256 AAAA... alice@laptop
$ ssh-keygen -l -f authorized_keys
256 SHA256:mAqR26dYZ8oL7Or4HlOdmNse7DGUJmvFWbrMrM6cTow ci-deploy@example (ED25519)
256 SHA256:R2eGVhsCNIhvMZsTFy+rM7ZlNnrnB68suxYalggus78 alice@laptop (ECDSA)
Both keys were read, options and comments included, and each fingerprint is listed. This is also a handy audit command: ssh-keygen -l -f ~/.ssh/authorized_keys shows every key a user accepts. The options (from sshd's documentation, not run here):
restrict turns OFF everything: port forwarding, agent forwarding, X11, pty and the user rc file. Then add back only what is needed (pty, port-forwarding...). Safest starting point
command="..." whatever the client asks to run, this command runs instead; the original request is in $SSH_ORIGINAL_COMMAND. Perfect for backup, deploy and monitoring keys
from="pattern-list" the key works only from these addresses or host names; ! excludes
expiry-time="YYYYMMDD" the key stops working after this date (OpenSSH 7.7 and newer)
principals="..." for certificates (next section)
no-agent-forwarding, no-port-forwarding, no-pty, no-X11-forwarding, no-user-rc switch off single features
permitopen="host:port" with port forwarding: only that destination may be tunnelled; permitlisten="..." the same for remote forwards
verify-required demand the PIN for a FIDO2 key
Habits: give every key its own comment and a line above it saying who and why; use restrict and command= for any key that is used by a machine; keep authorized_keys writable only by its owner (StrictModes checks it); watch it for changes (section 12), because adding a line to this file is how an attacker keeps access. For a large number of users, authorized_keys files do not scale: use certificates (next section), or AuthorizedKeysCommand to fetch keys from a central directory (not run).
6. SSH certificates: short-lived access and revocation
An SSH certificate is a public key signed by a certificate authority (CA) you control, together with a name, a validity period and restrictions. The server trusts the CA instead of a list of keys, so you add one line to each server ONCE and never edit authorized_keys again. Access ends when the certificate expires. The Creating SSH Keys page introduces the idea; this is the working detail, all of it run for real with ssh-keygen (the sshd side is shown without output).
Create the CA key (guard it better than any other key: whoever has it can mint access to everything that trusts it; keep it offline, on a hardware token, or in a signing service) and a user key, then sign the user key:
$ ssh-keygen -t ed25519 -N '' -C 'lab user CA' -f ca
$ ssh-keygen -t ed25519 -N '' -C 'alice@laptop' -f user
$ ssh-keygen -s ca -I "alice-2026-10" -n alice,deploy -V +1d -z 1001 -O source-address=192.0.2.0/24 -O no-agent-forwarding user.pub
(-q was used, so the signing itself printed nothing.) Read the certificate back:
$ ssh-keygen -L -f user-cert.pub
user-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:FQo1kARsSMQk4qyVyG6Z2V2hgkO2Sz2mU7/1cpb7tG0
Signing CA: ED25519 SHA256:GOLYDH0yv94nWTd6xA4u8pWNkRy7UzoavDQKA/RBof0 (using ssh-ed25519)
Key ID: "alice-2026-10"
Serial: 1001
Valid: from 2026-10-02T16:09:00 to 2026-10-03T16:10:38
Principals:
alice
deploy
Critical Options:
source-address 192.0.2.0/24
Extensions:
permit-X11-forwarding
permit-port-forwarding
permit-pty
permit-user-rc
What the fields mean: the Key ID appears in the server's log when the certificate is used (use something that names the person and the ticket), the serial is what you revoke by, the principals are the account names the certificate may log in as (alice may log in as alice or deploy, nothing else), and Valid is the time window. The certificate starts one minute in the past to tolerate clocks that disagree, and the times are shown in the local zone of the machine that printed them. Critical options (source-address here) must be honoured or the login is refused; extensions grant features. The default set has five (X11 forwarding, agent forwarding, port forwarding, pty and user rc); the listing shows only four, because -O no-agent-forwarding on the signing command removed permit-agent-forwarding, which is exactly how you withhold a feature. In the lab the certificate was valid for one day (-V +1d); in practice, make it short (minutes to a working day) and issue it on demand after the user proves who they are. -O clear removes all the default extensions first, and -O force-command=... pins a command, the same as command= in authorized_keys.
Host certificates solve the "first connection" problem from the other side: sign the server's host key once, and clients that trust the CA never see an unknown-host prompt:
$ ssh-keygen -s ca -I "web1-host" -h -n web1.example.test,192.0.2.10 -V +52w -z 2001 host_ed25519.pub
$ ssh-keygen -L -f host_ed25519-cert.pub | sed -n '1,8p'
host_ed25519-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com host certificate
...
Key ID: "web1-host"
Serial: 2001
Valid: from 2026-10-02T16:09:00 to 2027-10-01T16:10:38
One line in the client's known_hosts trusts every host the CA has signed for that name pattern, instead of one line per server:
@cert-authority *.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIA90Ybl...
The server side (not run): on every server, TrustedUserCAKeys /etc/ssh/user_ca.pub (the CA's PUBLIC key) and, to control which principals may log in as which account, AuthorizedPrincipalsFile /etc/ssh/principals/%u or AuthorizedPrincipalsCommand; for host certificates, HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub plus HostKey. A client uses its certificate automatically when ~/.ssh/id_ed25519-cert.pub sits next to the key, or with CertificateFile.
Expiry is the main safety feature, and revocation is the emergency brake. A certificate that is already past its window is not a risk:
$ ssh-keygen -s ca -I "alice-old" -n alice -V -3d:-1d -z 1002 old.pub
$ ssh-keygen -L -f old-cert.pub | grep -E 'Valid|Serial|Key ID'
Key ID: "alice-old"
Serial: 1002
Valid: from 2026-09-29T16:10:39 to 2026-10-01T16:10:38
Valid ended yesterday, so a server refuses it with no action from you. For a certificate that is still valid but should not be (a lost laptop), build a key revocation list (KRL) and put it on the servers with RevokedKeys /etc/ssh/revoked.krl (not run). The lab built one that revokes serial 1001 and tested two certificates against it:
$ ssh-keygen -k -f revoked.krl -s ca.pub <(printf 'serial: 1001\n')
$ ssh-keygen -Q -f revoked.krl user-cert.pub
user-cert.pub (alice@laptop): REVOKED
$ ssh-keygen -Q -f revoked.krl old-cert.pub
old-cert.pub (alice@laptop): ok
Serial 1001 is reported as REVOKED (exit status 1), while serial 1002 is ok (exit status 0, so you can use the command in scripts and CI). Give every certificate a unique serial number you record somewhere, because that number is what you will revoke. The trade-off: certificates need a small process (who signs, how people authenticate to the signer, where the CA key lives, how the KRL reaches the servers). For two servers and one admin, plain keys with restrictions are simpler; from a handful of people and servers upward, certificates win.
7. Algorithms: what OpenSSH 9.6 ships, and when to override it
SSH negotiates four things for each connection: how to exchange keys (kex), the cipher that encrypts the traffic, the MAC that protects its integrity (not needed with the AEAD ciphers chacha20-poly1305 and aes-gcm), and the host key and signature types. Both sides offer lists, and the first common choice, in the CLIENT's order, is used. The practical question is: should you edit the lists? The honest answer is: usually no. Current OpenSSH defaults are chosen by the OpenSSH developers and updated as the world changes, whereas a hand-written list from a blog post in 2018 will quietly lock you out of newer clients, or keep an algorithm that is now weak. Know what the defaults are, check what you can remove, and test before you commit.
How much does this version support, and what is the default? ssh -Q asks the client what it knows; ssh -G shows the effective configuration, including the defaults:
$ for q in kex cipher mac key sig; do printf '%-8s %s\n' $q "$(ssh -Q $q | wc -l)"; done
kex 13
cipher 10
mac 16
key 16
sig 11
$ ssh -T -G example.test | grep '^kexalgorithms' | cut -d' ' -f2 | tr ',' '\n' | nl
1 sntrup761x25519-sha512@openssh.com
2 curve25519-sha256
3 curve25519-sha256@libssh.org
4 ecdh-sha2-nistp256
5 ecdh-sha2-nistp384
6 ecdh-sha2-nistp521
7 diffie-hellman-group-exchange-sha256
8 diffie-hellman-group16-sha512
9 diffie-hellman-group18-sha512
10 diffie-hellman-group14-sha256
The first entry, sntrup761x25519-sha512, is a hybrid: it combines the classic curve25519 exchange with a post-quantum algorithm, so traffic recorded today cannot be decrypted by a future quantum computer. It is the DEFAULT first choice in 9.6, which is a good reason to keep the defaults and to keep OpenSSH up to date: you get such improvements for free. The default ciphers are chacha20-poly1305@openssh.com, aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com and aes256-gcm@openssh.com; the default MACs are:
$ ssh -T -G example.test | grep '^macs' | cut -d' ' -f2 | tr ',' '\n' | nl
1 umac-64-etm@openssh.com
2 umac-128-etm@openssh.com
3 hmac-sha2-256-etm@openssh.com
4 hmac-sha2-512-etm@openssh.com
5 hmac-sha1-etm@openssh.com
6 umac-64@openssh.com
7 umac-128@openssh.com
8 hmac-sha2-256
9 hmac-sha2-512
10 hmac-sha1
and the default host key algorithms (without the certificate variants) are ssh-ed25519, ecdsa-sha2-nistp256/384/521, the two FIDO ones (sk-...) and rsa-sha2-512 and rsa-sha2-256. Notice what is NOT there: the old ssh-rsa name (RSA with SHA-1), DSA, 3DES, CBC ciphers and MD5. They are supported (the client still knows them) but switched off by default. Here is exactly what is supported but not default, computed by comparing ssh -Q with the defaults:
$ comm -23 <(ssh -Q kex | sort) <(defaults | sort)
diffie-hellman-group14-sha1 diffie-hellman-group1-sha1 diffie-hellman-group-exchange-sha1
$ (same for ciphers)
3des-cbc aes128-cbc aes192-cbc aes256-cbc
$ (same for MACs)
hmac-md5 hmac-md5-96 hmac-md5-96-etm@openssh.com hmac-md5-etm@openssh.com hmac-sha1-96 hmac-sha1-96-etm@openssh.com
This is the legacy tail. If a scanner tells you "server supports weak algorithms", check whether the server is old, or whether someone enabled these on purpose. On a current sshd the defaults do not offer them, so no action is needed.
Change a list only with modifiers, not by replacing it. In the Kex, Ciphers, MACs, HostKeyAlgorithms and PubkeyAcceptedAlgorithms settings, a leading - removes names from the default list, + appends names to it, and ^ puts names at the front. This is how to remove one thing or temporarily allow one old algorithm, without rewriting the whole list. All three were run through the client parser with ssh -G:
$ ssh -T -G -o KexAlgorithms=-curve25519-sha256 example.test | grep '^kexalgorithms' | tr ',' '\n' | head -3
kexalgorithms sntrup761x25519-sha512@openssh.com
curve25519-sha256@libssh.org
ecdh-sha2-nistp256
$ ssh -T -G -o "Ciphers=+3des-cbc" example.test | grep '^ciphers' | tr ',' '\n' | tail -2
aes256-gcm@openssh.com
3des-cbc
$ ssh -T -G -o "Ciphers=^aes128-ctr" example.test | grep '^ciphers' | tr ',' '\n' | head -2
ciphers aes128-ctr
chacha20-poly1305@openssh.com
The first removed curve25519-sha256 and left the rest; the second added 3des-cbc at the end (a way to reach an ancient device, for one host only: put it in a Host block, never in Host *); the third moved aes128-ctr to the front.
A strict list, if a standard or a policy demands one. Some environments (CIS benchmarks, a security team) want a short explicit list. This one is a SUBSET of the defaults, so it only removes things and never adds a weaker algorithm (not run on a server; every name was checked against this OpenSSH, see below):
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
Compared with the defaults it removes these (computed with comm against the real default lists):
kex: diffie-hellman-group14-sha256 diffie-hellman-group-exchange-sha256 ecdh-sha2-nistp256 ecdh-sha2-nistp384 ecdh-sha2-nistp521
ciphers: aes128-ctr aes192-ctr
macs: hmac-sha1 hmac-sha1-etm@openssh.com hmac-sha2-256 hmac-sha2-512 umac-128@openssh.com umac-64-etm@openssh.com umac-64@openssh.com
The price is compatibility: an old client (an old PuTTY, a library from 2015, a switch or printer with SSH) that only knows the removed names gets "no matching key exchange method found" or "no matching cipher found" and cannot connect. Also, host keys: HostKeyAlgorithms limits which of the server's keys it offers. Before you apply a strict list to a production server, list your real clients, test each one against a spare server, and keep a way in (section 2).
Validate algorithm names, and use the right query. Every name must exist, or sshd refuses to start. The check is easy to script with ssh -Q, but each setting has its own query, and my first check used the wrong one: it reported rsa-sha2-512 and rsa-sha2-256 as "missing" from ssh -Q key, although the client's parser accepted them. The reason: ssh -Q key lists KEY TYPES (ssh-rsa, ssh-ed25519...), whereas rsa-sha2-512 is a SIGNATURE algorithm. The right queries are ssh -Q kex, cipher, mac, and HostKeyAlgorithms (or PubkeyAcceptedAlgorithms or sig) for the host key and signature names:
$ chk(){ for a in ${2//,/ }; do ssh -Q $1 | grep -qx -- "$a" || echo "MISSING $1: $a"; done; }
$ chk kex "$KEX"; chk cipher "$CIPH"; chk mac "$MACS"; chk HostKeyAlgorithms "$HKA" (no output: every name exists)
$ chk cipher "chacha20-poly1305@openssh.com,aes256-gcm@openssh.org"
MISSING cipher: aes256-gcm@openssh.org
The last line is a typo (.org for .com), caught. And a bad name in a client option is rejected at once, with the offending name:
$ ssh -G -o Ciphers=bogus example.test
command-line line 0: Bad SSH2 cipher spec 'bogus'.
$ ssh -G -o KexAlgorithms=nosuchkex example.test
Unsupported KEX algorithm "nosuchkex"
command-line line 0: Bad SSH2 KexAlgorithms 'nosuchkex'.
For the server, sshd -t does the same check (not run). The lists above were also fed to the client parser together, and it accepted them. This checks only that the names are valid on THIS OpenSSH, not that your clients speak them.
Tools: ssh-audit (a separate program, pip or apt) scans a server from outside and grades its algorithms, host keys and known weaknesses; it was not installed on the lab machine. Lynis and OpenSCAP check the whole machine (see the Scanning Tools page).
Two version-specific settings worth knowing: RequiredRSASize (OpenSSH 9.1+) refuses short RSA keys and certificates, and PerSourcePenalties (OpenSSH 9.8+, so NOT in the 9.6 of Ubuntu 24.04) makes sshd itself penalise addresses that fail to log in, much like a built-in fail2ban. Check your version with ssh -V and sshd -V before you rely on either.
8. Host keys and the first connection
The first time you connect to a server, your client has no way to know the key it is shown is the right one. OpenSSH's answer is "trust on first use" (TOFU): it asks, then remembers the key in ~/.ssh/known_hosts and complains loudly if it ever changes. Two weaknesses: you probably typed yes without checking, and the file itself needs care.
Verify the fingerprint through a second channel the first time: the cloud console or the provider's log of the instance (many print the host key fingerprints at first boot), a colleague, or the output of ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub run on the server's console. Compare it with the SHA256 value ssh shows you. Do not use ssh-keyscan as proof: it just asks the same server over the same network.
Keep known_hosts tidy. All of this was run on a scratch known_hosts file:
$ cat kh (three hosts; names in the clear)
web1.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP17FU...
db1.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP17FU3...
[bastion.example.test]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...
A plain known_hosts is a map of your network: it lists every server you log in to, and anyone who reads the file (malware, an attacker on your laptop) learns where to go next. Hash the names:
$ ssh-keygen -H -f kh_hashed
WARNING: kh_hashed.old contains unhashed entries
Delete this file to ensure privacy of hostnames
$ cut -c1-70 kh_hashed
|1|WwDsQtKSKDxZZx674AXOFkfd7WE=|WmjN/Zj5YR9Yv6Snk/BknXXungA= ssh-ed255
|1|N98WTiXGd36TqJ4+z6+RYDR2MDA=|ox6AySXfVGta/bxNXCU2a+dvu70= ssh-ed255
|1|Ik3QWq7Zv1CGsZYSGtuzptjhCIo=|Y5O4B2dNf7oksXLC/iR4OYAx5M8= ssh-ed255
Note the warning: the old unhashed file is kept as .old and must be deleted by you, or the point is lost. A hashed entry can still be looked up by name, so nothing stops working:
$ ssh-keygen -F db1.example.test -f kh_hashed | head -2 | cut -c1-80
# Host db1.example.test found: line 2
|1|N98WTiXGd36TqJ4+z6+RYDR2MDA=|ox6AySXfVGta/bxNXCU2a+dvu70= ssh-ed25519 AAAAC3N
To make ssh do this for every new host, set HashKnownHosts yes in ~/.ssh/config (section 9). Remove a host after a legitimate rebuild (not "to make the warning go away"):
$ ssh-keygen -R db1.example.test -f kh_r
# Host db1.example.test found: line 2
kh_r updated.
Whenever you see "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", stop. It is either a rebuilt server, a changed key, or an attacker; find out which (ask, check the console) before you remove the old line. Another useful check: the same key under several names is how a cloned machine shows up. In the lab file the three entries share one key, and counting fingerprints exposes it:
$ ssh-keygen -l -f kh | awk '{print $2}' | sort | uniq -c
3 SHA256:z73Z0WgXQQkVrYZWYo3SS2yEM5slewSi9NJwaaR7V2Q
Three names, one fingerprint. Real servers must have unique host keys: a golden image or a copied VM keeps the same host keys on every clone unless they are regenerated at first boot (cloud-init does this; check your images).
Other ways to trust a host key without typing yes: host certificates (previous section: one @cert-authority line covers every server) and DNS: publish the key's fingerprint as an SSHFP record and set VerifyHostKeyDNS ask or yes in the client. ssh-keygen prints the record for you:
$ ssh-keygen -r web1.example.test -f host_ed25519.pub
web1.example.test IN SSHFP 4 1 c63d2ae3134102a31195b2c150bcece981e286cd
web1.example.test IN SSHFP 4 2 cfbdd9d16817410915ad8656628dd24b6c84339b257b04a2f4d27069a47b5764
(4 is the ed25519 key type; 1 and 2 are SHA-1 and SHA-256.) DNS records are only trustworthy if the zone is signed with DNSSEC and your resolver validates it; otherwise an attacker who controls the DNS answer controls the fingerprint, and you gained nothing. Host certificates are usually the better answer.
On the server: use only the host key types you need (ed25519 and rsa 3072 or better), remove the old ones (ssh_host_dsa_key, ssh_host_ecdsa_key if you do not want them), and rotate a key only with a plan: every client gets a changed-host-key warning. OpenSSH can deliver the new keys to clients for you with UpdateHostKeys yes (the default for the client in recent versions; accepted by the parser).
9. The client: ~/.ssh/config done right
The client config is where convenience and security meet. Four lessons from the lab, run with ssh -G, which prints the settings ssh WOULD use for a host without connecting. Use it every time you edit this file.
Lesson 1: the FIRST value found for an option wins, not the last. Here is a hardened-looking file in which the author wrote ForwardAgent yes and then ForwardAgent no, both under Host *:
$ cat cfg
Host prod-db
HostName 10.0.0.5
User dbadmin
ProxyJump bastion.example.test
IdentityFile ~/.ssh/id_prod
ForwardAgent no
Host bastion.example.test
User jump
Port 2222
Host *
IdentitiesOnly yes
ForwardAgent yes
HashKnownHosts yes
ServerAliveInterval 30
ServerAliveCountMax 3
ForwardAgent no
StrictHostKeyChecking yes
VerifyHostKeyDNS no
PasswordAuthentication no
KexAlgorithms -diffie-hellman-group14-sha256
$ ssh -F cfg -G prod-db | grep -E '^(hostname|user|port|proxyjump|identitiesonly|forwardagent|hashknownhosts|serveraliveinterval|stricthostkeychecking|passwordauthentication|identityfile) '
user dbadmin
hostname 10.0.0.5
port 22
hashknownhosts yes
identitiesonly yes
passwordauthentication no
stricthostkeychecking true
serveraliveinterval 30
identityfile ~/.ssh/id_prod
forwardagent no
proxyjump bastion.example.test
$ ssh -F cfg -G other.example.test | grep -E '^(user|port|forwardagent|identitiesonly|stricthostkeychecking) '
user demo
port 22
identitiesonly yes
stricthostkeychecking true
forwardagent yes
For prod-db everything looks right, because its own block says forwardagent no first. But for any other host (other.example.test matches only Host *), the effective value is forwardagent yes: the first line wins, so the later "ForwardAgent no" was ignored, and the agent is forwarded to every server except the ones with their own block. That is the dangerous kind of mistake: it looks fine, and nothing complains. Rules that follow: put specific Host blocks first and Host * last; inside a block, never repeat a keyword; and run ssh -G on a host that only matches Host * to see the defaults you really get. (The KexAlgorithms line with a minus worked as designed: ssh -G showed the list ending at diffie-hellman-group18-sha512, with group14-sha256 gone.)
Lesson 2: IdentitiesOnly yes stops ssh offering every key it can find. Without it, ssh offers the keys that the agent holds and every default identity file, one after another. The default identity files:
$ ssh -T -G example.test | grep '^identityfile'
identityfile ~/.ssh/id_rsa
identityfile ~/.ssh/id_ecdsa
identityfile ~/.ssh/id_ecdsa_sk
identityfile ~/.ssh/id_ed25519
identityfile ~/.ssh/id_ed25519_sk
identityfile ~/.ssh/id_xmss
Add the agent's keys on top, and a user with six keys makes six failed attempts before reaching the right one. The server counts each (MaxAuthTries) and answers "Too many authentication failures" (shown from the server's behaviour, not run) before the right key was even tried. It also tells the server the names and fingerprints of all your keys, which is information you do not want to give to every host. IdentitiesOnly yes together with an explicit IdentityFile per Host makes ssh offer exactly one key. (An IdentityFile line in a config that points to a missing file is simply skipped.)
Lesson 3: do not forward the agent; jump instead. ProxyJump (or -J) connects through a bastion without giving the bastion your agent. ssh -G shows how it was parsed: proxyjump bastion.example.test. (By the documentation, the jump host is looked up in the config like any other host, so the User and Port of the bastion's own Host block apply to the first hop; that part needs a connection and was not run. Section 10 has the details.)
Lesson 4: unknown or server-only options are errors, at once. A typo or an sshd keyword in the client config is rejected, which is a safe failure:
$ ssh -F cfg_bad -G x
cfg_bad: line 2: Bad configuration option: nosuchoption
cfg_bad: terminating, 1 bad configuration options
$ ssh -F cfg_mix -G x (the file contained "PermitRootLogin no", an sshd_config keyword)
cfg_mix: line 2: Bad configuration option: permitrootlogin
cfg_mix: terminating, 1 bad configuration options
Mixing up ssh_config (the client) and sshd_config (the server) is a common slip; the error says the keyword does not belong. Config file permissions: a config that is owned by someone else or writable by others is refused with "Bad owner or permissions on ..." when it is the default ~/.ssh/config (documented behaviour; with an explicit -F file the lab did NOT see this check, a world-writable test file was accepted, so do not rely on it): keep ~/.ssh at 700 and the config at 600.
Other client settings that are worth it (each was checked to exist with ssh -G -o ..., which prints the option back):
AddKeysToAgent confirm add a key to the agent when first used, and ask before each use (needs ssh-askpass)
HashKnownHosts yes hide host names in known_hosts
StrictHostKeyChecking yes never auto-accept an unknown key (use ask for interactive use); true is shown by ssh -G
VerifyHostKeyDNS ask only if you publish SSHFP and sign with DNSSEC
UpdateHostKeys yes accept new host keys the server announces (the parser prints true)
RequiredRSASize 3072 refuse RSA keys and certificates below 3072 bits
PubkeyAcceptedAlgorithms -ssh-rsa never offer the SHA-1 RSA signature (already the default; here as an example of the - modifier)
ObscureKeystrokeTiming yes (default) hides typing rhythm; make sure you have not turned it off
ControlMaster auto, ControlPath ~/.ssh/cm-%C, ControlPersist 60 reuse one connection for many sessions (faster logins); the socket gives anyone who can read it a logged-in channel, so keep the folder private (700) and use a short ControlPersist
IdentityAgent none do not use any agent for this host
ServerAliveInterval 30, ServerAliveCountMax 3 detect dead connections
CertificateFile ~/.ssh/id_ed25519-cert.pub use a certificate (section 6)
10. The agent and forwarding: how keys leak
ssh-agent holds your unlocked private keys in memory so you type the passphrase once. That is a convenience with a security cost, which you can reduce. The lab ran a private agent (its own socket) to look at the facts:
$ eval "$(ssh-agent -a "$PWD/agent.sock" -s)"
$ stat -c '%A %U' agent.sock
srw------- demo
The socket is mode 600, owned by you: only your own processes (and root) can talk to it. But that is the weakness. Anything running as you can ask the agent to sign, without knowing the passphrase or seeing the key:
$ SSH_AUTH_SOCK="$PWD/agent.sock" ssh-add -l
256 SHA256:mAqR26dYZ8oL7Or4HlOdmNse7DGUJmvFWbrMrM6cTow lab@example (ED25519)
A script, a malicious package or an attacker with a shell as your user can authenticate as you to every server your keys open, for as long as the agent runs. Reduce the exposure with constraints when you add the key:
$ ssh-add -t 60 -c k_ed25519
Identity added: k_ed25519 (lab@example)
Lifetime set to 60 seconds
The user must confirm each use of the key
$ ssh-add -l
256 SHA256:mAqR26dYZ8oL7Or4HlOdmNse7DGUJmvFWbrMrM6cTow lab@example (ED25519)
$ ssh-add -D
All identities removed.
-t 60 makes the agent forget the key after sixty seconds (use a longer time that suits your work, such as -t 4h), and -c makes the agent ask for confirmation for each signature, so a silent request from malware shows up as a prompt. ssh-add -D removes all keys; ssh-add -l lists them; ssh-agent -k stops the agent. A hardware key (section 4) is better again: the private part never leaves the token, and each signature needs a touch. In the config, AddKeysToAgent confirm (or yes with a lifetime in ssh-add) does the same automatically.
Agent forwarding (ssh -A, or ForwardAgent yes) is where the real damage happens. It makes your local agent available on the remote machine, through a socket there. Root on that remote machine (or anyone who compromises it) can use your agent socket to sign requests as you, to every other server your keys open, while you are connected. This is the classic "jump host compromise" and it is why section 9 turned ForwardAgent off. Use ProxyJump instead, which does not hand the middle host anything it can use:
ssh -J jump@bastion.example.test dbadmin@10.0.0.5 one command
Host prod-db / ProxyJump bastion.example.test in the config (section 9)
The bastion forwards the encrypted bytes and cannot read or reuse them. If you truly need agent forwarding to one trusted host, enable it for that Host block only, with ForwardAgent yes, and keep -c on the keys.
Forwarding on the server side (not run). Turn off what nobody needs; each is a way to tunnel traffic through the server:
AllowAgentForwarding no (or per Match block)
AllowTcpForwarding no or local, remote, yes; "no" stops port forwarding for normal users
GatewayPorts no remote forwards bind to localhost only (the default; keep it)
PermitTunnel no no tun/tap VPN devices
X11Forwarding no
PermitUserEnvironment no users cannot inject environment variables (LD_PRELOAD tricks)
DisableForwarding yes one switch that turns off all forwarding (X11, agent, TCP, tunnels, streamlocal), OpenSSH 7.4 and newer
Then allow exceptions where needed with Match, for a group or an address:
Match Group tunnelusers
AllowTcpForwarding local
PermitOpen 10.0.0.5:5432
and for file-transfer-only accounts, a chrooted SFTP user:
Match Group sftponly
ChrootDirectory /srv/sftp/%u must be owned by root and not writable by others, or sshd refuses
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
(none of this was run in the lab; test on a spare server, and read sshd -T -C user=...,host=...,addr=... for a user that should match the block.)
11. Reduce exposure: where sshd listens and who can reach it
The best protection against an attack on SSH is to not let the attacker reach it. All of this needs a real server and was not run:
ListenAddress: bind sshd only to the address you need (the management interface or the VPN address), not 0.0.0.0, and check with sudo ss -ltnp | grep sshd (see the ss page). A server behind a VPN need not offer SSH to the world at all.
Firewall allow-list: allow port 22 only from your admin networks (ufw allow from 192.0.2.0/24 to any port 22 proto tcp, or the nftables set in the Firewall Basics page), and in a cloud the security group or network ACL. If you must be open to the internet, use ufw limit 22/tcp or an nftables rate limit to slow down brute forcing.
Port: moving SSH to another port is not security (a scan finds it in minutes), but it removes most of the log noise from the automated attacks on port 22. Do it only if you will remember it everywhere (config, firewall, monitoring), and never instead of keys.
MaxStartups and LoginGraceTime (section 3) limit how many unauthenticated connections an attacker can hold open.
Bastion host: one hardened machine that is the only way in from outside; everything else accepts SSH only from it. Combine with ProxyJump (section 10) and certificates with short validity.
Brute-force blocking: with key-only login a password guess can never succeed, so the noise is harmless, but it fills logs and costs CPU. Tools that ban repeated failures at the firewall (see the fail2ban page, which also shows why its default SSH mode never sees a key-only scanner), or OpenSSH 9.8's PerSourcePenalties, cut it down. They are a tidy-up, not a defence: the defence is the key-only login.
Zero-trust and VPN options (WireGuard, Tailscale, cloud-native session managers) remove the open port entirely; they are worth considering for anything that matters.
12. Logging, auditing and detection
You cannot respond to what you do not see. Not run (needs a server):
LogLevel VERBOSE in sshd_config: logs the fingerprint of the key (or the certificate Key ID and serial) used at every login. Without it you only know that "someone logged in as deploy"; with it you know WHICH key. This is why certificate Key IDs should name a person and a ticket.
Where the log lines are: journalctl -u ssh (Ubuntu and Debian; the unit is called sshd on Red Hat), /var/log/auth.log on Debian and Ubuntu, /var/log/secure on the Red Hat family. See the journalctl page for the filters.
Useful questions: journalctl -u ssh --since today -g 'Accepted' (who got in), -g 'Failed' (who failed), -g 'Invalid user' (who tried names that do not exist), and count by address with the awk pipelines from the text-processing pages. last and lastb show past logins and failures.
Alert on: a successful login from a new country or network, any login as root (it should be impossible), a key that has never logged in before, many failures followed by a success from the same address, and a login outside working hours for a person who does not work then.
Watch the files that grant access: authorized_keys of every user, /etc/ssh/sshd_config and /etc/ssh/sshd_config.d, the CA public key and the revocation list, host keys. With auditd: -w /etc/ssh/sshd_config -p wa -k sshd and -w /home/alice/.ssh/authorized_keys -p wa -k ssh_keys. A new line in authorized_keys is how an attacker keeps access after you change the password.
Inventory the keys: a script that runs ssh-keygen -l -f over every authorized_keys on a server (as root) and compares the fingerprints with the list of people who should have access. Do it every quarter, and when someone leaves.
Session recording (script, tlog, or a bastion with recording) for the most sensitive systems; and ship the logs off the machine, so an attacker cannot erase them.
13. Prove it from outside
Hardening that was never tested is hope. From another machine (all not run, they need your server):
ssh -vv user@server read the "kex:", "cipher:" and "MAC:" lines to see what was negotiated, and the "Authentications that can continue:" line: it must say publickey and nothing else
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no user@server must fail with Permission denied (publickey), with no password prompt
ssh -o KexAlgorithms=diffie-hellman-group14-sha1 user@server must fail with "no matching key exchange method found" (an old algorithm is refused)
ssh -o HostKeyAlgorithms=ssh-rsa user@server the SHA-1 RSA signature must be refused
ssh root@server must be refused, even with the right key
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin|authenticationmethods|allowgroups|maxauthtries|x11forwarding|allowtcpforwarding' what the daemon REALLY uses
ssh-audit server grades algorithms and host keys (not installed on the lab machine)
A port scan from outside (nmap -p 22 --script ssh2-enum-algos server; nmap was not installed in the lab) confirms who can reach the port and which algorithms are offered.
Automate these checks, so a later change that breaks the hardening is noticed: a small script run by CI or cron after every change, which fails if any of the expectations above is not met. See the production script template page for a structure with tests.
14. A complete hardened configuration
Put this in /etc/ssh/sshd_config.d/00-hardening.conf (the 00 makes it sort first, and the first value wins, see section 2). The algorithm names were all checked against ssh -Q on OpenSSH 9.6; nothing else in this file was run, because the lab has no sshd. Comments after # are for you; delete lines you do not need, and remove the strict algorithm block if you have old clients:
# --- who and how
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no
AllowGroups sshusers
StrictModes yes
MaxAuthTries 3
LoginGraceTime 20
MaxStartups 10:30:60
MaxSessions 4
RequiredRSASize 3072
# --- certificates (if you use them)
# TrustedUserCAKeys /etc/ssh/user_ca.pub
# AuthorizedPrincipalsFile /etc/ssh/principals/%u
# RevokedKeys /etc/ssh/revoked.krl
# --- forwarding
AllowAgentForwarding no
AllowTcpForwarding no
GatewayPorts no
PermitTunnel no
X11Forwarding no
PermitUserEnvironment no
# --- sessions and logging
ClientAliveInterval 300
ClientAliveCountMax 2
LogLevel VERBOSE
Banner none
# --- network
# ListenAddress 10.0.0.5
# --- algorithms: strict subset of the 9.6 defaults (optional; see section 7)
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
Then: sudo sshd -t && sudo sshd -T | less && sudo systemctl reload ssh (the service is sshd on the Red Hat family), and test from a second session (sections 2 and 13).
The client side, in ~/.ssh/config (use ssh -G on a host that matches only Host *, as in section 9, to confirm the result):
Host prod-db
HostName 10.0.0.5
User dbadmin
ProxyJump bastion.example.test
IdentityFile ~/.ssh/id_prod
Host bastion.example.test
User jump
Port 2222
IdentityFile ~/.ssh/id_bastion
Host *
IdentitiesOnly yes
ForwardAgent no
AddKeysToAgent confirm
HashKnownHosts yes
StrictHostKeyChecking yes
ServerAliveInterval 30
ServerAliveCountMax 3
PasswordAuthentication no
15. Locked out: recovery
Stay calm, and go through these in order (all need a console or other access, not run):
1. Is it really SSH? Check the network (ping, ss on the server through the console), the firewall (did your rule allow your address?), and whether sshd is running (systemctl status ssh).
2. Read the server's log from the console: journalctl -u ssh -n 50. It names the reason for a refusal: "Authentication refused: bad ownership or modes for directory /home/alice" (StrictModes), "User alice not allowed because not listed in AllowUsers", "no matching key exchange method found", "Invalid user".
3. Use the console or cloud rescue to restore the last good configuration (sshd_config.good, or git checkout in /etc), run sudo sshd -t, and reload.
4. Test the break-glass key and the emergency account, then fix the real problem. If the rollback timer from section 2 was running, wait for it.
5. Afterwards, write down what happened, what the log said, and what check would have caught it, and add that check to the "prove it" list.
16. Common problems
Permission denied (publickey) the key is not in the right account's authorized_keys, the wrong key is offered (use ssh -i KEY -o IdentitiesOnly=yes, and ssh -v), the user is not in AllowGroups or AllowUsers, the key is expired (expiry-time) or the certificate principal does not match the account, or StrictModes refuses it because ~, ~/.ssh or authorized_keys is group- or world-writable (home 755 or tighter, ~/.ssh 700, authorized_keys 600). On Red Hat family systems, SELinux labels can also block it: restorecon -Rv ~/.ssh
Too many authentication failures the client offered many keys before the right one; use IdentitiesOnly yes with a single IdentityFile for that host (section 9), or ssh -o IdentitiesOnly=yes -i KEY
no matching key exchange method found / no matching cipher found / no matching host key type found the two sides share no algorithm: the server list was made too strict or the client is old. Upgrade the client or relax the list for the legacy device: ssh -o KexAlgorithms=+diffie-hellman-group14-sha1 host (for that one host, in its own Host block)
no mutual signature algorithm an old RSA key signed with SHA-1 (ssh-rsa) is refused by a server that accepts only rsa-sha2-*: use a new ed25519 key, rather than re-enabling ssh-rsa
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! a rebuilt server, a new host key, or an attacker: verify the new fingerprint through a second channel; only then ssh-keygen -R host
Host key verification failed the key is unknown and StrictHostKeyChecking yes refuses to ask (in scripts, or BatchMode): add the verified key to known_hosts first
bad ownership or modes for directory ... StrictModes on the server: chmod go-w on the home folder and ~/.ssh, and own them as the user
Bad configuration option: ... (client) a typo, an sshd keyword in ssh_config (permitrootlogin is the classic), or an option from a newer OpenSSH than the client: ssh -V
Connection closed by ... [preauth] in the server log the client gave up or was dropped before authenticating: MaxStartups or LoginGraceTime limits, a firewall, a scanner, or a client with no usable algorithm
The change is ignored a drop-in with an earlier name sets the same keyword first (the first value wins): sshd -T shows the winner, and grep -r KEYWORD /etc/ssh/sshd_config /etc/ssh/sshd_config.d/
Agent has no keys / "Could not open a connection to your authentication agent" no agent is running or SSH_AUTH_SOCK is not set in this shell: eval "$(ssh-agent -s)", then ssh-add -t 4h KEY
Passphrase is asked every time no agent holds the key: use AddKeysToAgent yes (or confirm) in the config, or ssh-add once
A key is "too open" and ignored chmod 600 the private key and 700 ~/.ssh (the lab message: "Permissions 0644 for 'k_open' are too open ... This private key will be ignored")
17. Tips
Keys only, no root, no passwords: three lines in sshd_config that remove the most common ways in.
Put a long passphrase on every private key and raise the KDF rounds (ssh-keygen -a 100 or ssh-keygen -p -a 200); better, use a hardware key. On the lab machine 100 rounds made each password guess about six times slower.
Make every key do one job and restrict it: restrict, command=, from= and expiry-time in authorized_keys.
Prefer SSH certificates when more than a few people and servers are involved: short validity, principals, a serial number to revoke, one CA line on each server.
Keep the OpenSSH defaults and patch often; override with the - modifier only to remove something, and validate names with ssh -Q (use the right query for each setting).
In ~/.ssh/config the first match wins: specific hosts first, Host * last, no repeated keywords, and check with ssh -G on a host that only matches Host *.
Never forward the agent to a host you do not fully trust; use ProxyJump. Constrain keys in the agent with ssh-add -t and -c.
Verify host keys the first time through a second channel, hash known_hosts, and never remove a changed-key warning without finding out why.
Change SSH only with a second session open, a console you have tested, sshd -t before every reload, and a rollback timer for risky changes.
Log at VERBOSE, watch the files that grant access, and test the hardening from outside after every change.