SSH hardening: keys, ciphers, and CA-signed certificates

Disable passwords, pick modern ciphers, and issue SSH certificates so you stop managing authorized_keys by hand.

Aug 5, 2025·Updated ·5 min readIntermediate·By SecOpsLog · documentation-verified

Most SSH hardening guides were written for an OpenSSH that no longer ships. On a current release the key agreement is post-quantum by default (mlkem768x25519-sha256 since 10.0), AES-GCM is preferred over CTR, DSA is gone, X11 forwarding is off, and repeated authentication failures from one address are penalised without fail2ban. The settings that still matter are about who may log in and how they prove it, and the biggest structural improvement is replacing hand-copied authorized_keys files with a certificate authority that issues keys with an expiry.

What to change, and what the defaults already do

SettingCurrent defaultSet it toWhy
PasswordAuthenticationyesnopasswords are the credential that brute force and phishing can obtain
KbdInteractiveAuthenticationyesnothe other path to a password prompt (PAM); off unless you run MFA through it
PermitRootLoginprohibit-passwordnonamed accounts plus sudo give you an audit trail; root does not
AllowGroupsunset (everyone)ssh-usersa directory that returns every employee should not mean every employee can reach production
MaxAuthTries63a client offering many keys still fits; a guesser does not
PerSourcePenaltiesenabled since 9.8keepsshd throttles addresses that fail repeatedly; check it is not disabled by a vendor default
KexAlgorithms, Ciphers, MACsmodern set (10.x)pin only on older buildson 10.x the defaults are the hardened set; pinning on an old build removes legacy algorithms a scanner will flag
AllowAgentForwardingyesno on jump hostsa forwarded agent on a compromised host is a key you did not copy
/etc/ssh/sshd_config.d/10-hardening.conf
# picked up through the Include at the top of sshd_config; lowest number wins on conflicts
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowGroups ssh-users
MaxAuthTries 3
AllowAgentForwarding no
LoginGraceTime 30
# certificate authority (below); comment out until the CA key exists on every host
TrustedUserCAKeys /etc/ssh/user_ca.pub
RevokedKeys /etc/ssh/revoked_keys

Exceptions go in Match blocks at the end of the file rather than in weaker global defaults. A deployment account that must run one command from one network gets Match User deploy Address 10.0.4.0/24 with ForceCommand and PermitTTY no; a jump host allows agent forwarding for nobody and TCP forwarding for the admin group only. For connections that match, the keywords inside the block override the global values, and where several blocks match the first one wins for each keyword. sshd -T -C user=deploy,addr=10.0.4.9 shows exactly what such a connection would get, which is the check to run before trusting a Match you cannot easily test by logging in.

The change procedure is the hardening

Every setting in that file can lock every administrator out at once, and the lockout is silent until the next login. The procedure is therefore fixed: validate the merged configuration, reload rather than restart (existing sessions survive a reload), and prove a new login works from a second terminal before closing the one you are in. On a host with a serial console or an out-of-band console the risk is an inconvenience; on a cloud VM without one it is a rebuild.

bash — validate, reload, prove a new session, then close the old one
sshd -T | grep -iE "^(passwordauthentication|permitrootlogin|allowgroups|kexalgorithms)"
passwordauthentication no
permitrootlogin no
allowgroups ssh-users
kexalgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,…
sshd -T prints the effective values after every Include and Match block; it exits non-zero on a syntax error
systemctl reload ssh && ssh -o ConnectTimeout=5 admin@bastion.acme.dev true && echo "new session ok"
new session ok

A certificate authority instead of five hundred authorized_keys files

With authorized_keys, access is a file on every host, offboarding is a fleet-wide edit, and a key that leaked in 2023 still works today. With an SSH CA, hosts trust one public key (TrustedUserCAKeys), users present a certificate signed by it, and the certificate carries an expiry and a list of principals. Issuing an eight-hour certificate each morning through an identity-provider login turns offboarding into "stop issuing" and makes a stolen key a stolen eight hours. Vault's SSH secrets engine and step-ca both automate the signing step; the mechanics below are what they wrap.

ca-sign.sh (on the signing host, not on servers)
# once: the CA key pair; the private half never leaves this host
ssh-keygen -t ed25519 -f user_ca -C "acme-user-ca"
# per login: sign the user's public key into an 8-hour certificate
ssh-keygen -s user_ca -I "alice@acme" -n alice,deploy -V +8h \
-O permit-pty -O no-agent-forwarding id_ed25519.pub
# -> id_ed25519-cert.pub, presented automatically by ssh alongside the key
# on every server: TrustedUserCAKeys /etc/ssh/user_ca.pub
# the -n principals must include the account name being logged into (or an AuthorizedPrincipalsFile mapping)

Revocation before expiry is a key revocation list generated with ssh-keygen -k and distributed to the RevokedKeys path; with eight-hour certificates the list stays short, because most revocations happen by waiting. Host certificates work the same way in the other direction: a host CA signs each server's host key, clients get @cert-authority in known_hosts, and StrictHostKeyChecking=no disappears from automation along with the man-in-the-middle exposure it introduced.

bash — the client side of certificate login
ssh -v alice@app-14.acme.dev 2>&1 | grep -E "Offering|Authenticat|Server accepts"
debug1: Offering public key: /home/alice/.ssh/id_ed25519 ED25519-CERT SHA256:… agent
debug1: Server accepts key: /home/alice/.ssh/id_ed25519 ED25519-CERT SHA256:… agent
debug1: Authentication succeeded (publickey).
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub | grep -E "Valid|Principals" -A2
Valid: from 2026-09-12T08:00:00 to 2026-09-12T16:00:00
Principals: alice deploy
Roll the CA out before removing keys
Add TrustedUserCAKeys everywhere, issue certificates, and confirm logins for a week while authorized_keys still works. Then remove the static keys. Doing it in the other order produces a fleet where the only working credentials are the ones you were trying to retire. Keep one break-glass key in a vault with its own procedure; it is not an authorized_keys entry on a host.

The host firewall in front of sshd is the other half of the edge: an nftables ruleset that admits port 22 only from the bastion or the VPN range removes most of the traffic PerSourcePenalties would otherwise handle, and the auditd rules that watch sshd_config.d and authorized_keys are how you learn that someone changed either of them.

Related posts

Quick reference