SSH Security Best Practices 2026: OpenSSH 10.6, ML-KEM and FIDO2 Keys
I open more SSH sessions than I would like to admit for someone who has spent fourteen years telling teams to stop logging into servers. Most SSH security best practices you find in search results were written for OpenSSH 7 or 8, and the ground has moved. Between April 2025 and October 2026 OpenSSH shipped seven releases: ML-KEM post-quantum key exchange became the default, DSA was deleted, the server's pre-authentication code moved into its own binary, and the release notes started crediting AI-assisted bug reports. This post covers what changed, which key types and access patterns I would pick today, a hardened sshd_config I validated with sshd -t, and what regreSSHion and the xz backdoor still teach about running sshd as a fleet service.
What changed in OpenSSH 10.x (2025 to 2026)?
OpenSSH 10.0, released on 9 April 2025, did four things that matter operationally. It made mlkem768x25519-sha256, a hybrid of NIST's ML-KEM and classic X25519, the default key agreement. It removed DSA support entirely, finishing a deprecation that started in 2015. It moved the user-authentication code into a new sshd-auth binary, so the pre-authentication attack surface lives in its own address space (9.8 had already split per-connection work into sshd-session). And it dropped finite-field Diffie-Hellman from the server's default list. A small trap: it announces itself as SSH-2.0-OpenSSH_10.0, and the notes warn that scripts matching OpenSSH_1* will misread it.
OpenSSH 10.1 (6 October 2025) made the client print a warning whenever a connection negotiates key exchange that is not post-quantum, controlled by a new WarnWeakCrypto option. It also moved ssh-agent sockets from /tmp into ~/.ssh/agent, so a process that can read /tmp no longer gets ambient access to your keys, and it rejected control characters in command-line usernames to block shell injection through ProxyCommand %r expansion. 10.2 followed four days later to fix a ControlPersist regression.
The 2026 releases were about tightening edges. 10.3 (2 April) changed certificate semantics: a certificate with an empty principals list used to match any user when trusted through an authorized_keys principals= option, and now matches nobody. 10.4 (6 July) added an experimental ML-DSA-44 plus Ed25519 composite signature and made the transport stricter during rekeying. 10.5 (11 August) fixed an agent-locking bug that, while the agent was locked, let a forwarded agent use keys that had destination restrictions. 10.6 (6 October) enabled the hybrid ssh-mldsa44-ed25519 signature algorithm, added WarnWeakCrypto to the server, and disabled the LZ77 compression dictionary after a 2026 paper showed plaintext recovery across multiplexed channels sharing one compression context.
The most interesting paragraph is not a feature. The 10.5 and 10.6 notes say the team now receives a large number of security reports produced by or with AI models, has seen AI-found bugs independently rediscovered by other researchers, and will ship more frequent releases rather than batch fixes. Several 10.5 and 10.6 hardening changes credit Anthropic's Chris Rohlf, in 10.6 explicitly in collaboration with Claude. I covered the wider trend in AI found the bugs; for SSH the practical consequence is simple: your patch latency is now a security control.
Is SSH post-quantum secure in 2026?
For key exchange, mostly yes, and earlier than most people think. OpenSSH's own post-quantum page explains the threat: an attacker records encrypted sessions today and decrypts them once a large enough quantum computer exists ("store now, decrypt later"). OpenSSH has defaulted to a post-quantum hybrid since 9.0 in April 2022 (sntrup761x25519-sha512), added the NIST-standardised ML-KEM hybrid in 9.9 and made it the default in 10.0. Signatures are a different story: breaking a host or user key later does not decrypt recorded traffic, so there is no retroactive risk and no rush.
Your exposure is old servers. OpenSSH 8.9 was the first release to put sntrup761 in the default key-exchange list, so anything older, such as Ubuntu 20.04 (8.2) or Debian 11 (8.4), cannot negotiate a post-quantum exchange, and a 10.1 or later client will warn on every connection. I reproduced both behaviours locally with an OpenSSH 10.3p1 client against a server restricted to curve25519-sha256:
# Which key exchange did this connection use? (OpenSSH 10.3p1 client)
ssh -v deploy@web-1 exit 2>&1 | grep 'kex: algorithm'
# debug1: kex: algorithm: mlkem768x25519-sha256
# Against a server without PQ kex, a 10.1+ client prints:
# ** WARNING: connection is not using a post-quantum key exchange algorithm.
# ** This session may be vulnerable to "store now, decrypt later" attacks.
# ** The server may need to be upgraded. See https://openssh.com/pq.html
# Fleet audit: offer only PQ algorithms; non-PQ servers fail fast.
# Key exchange happens before authentication, so no login is needed.
for h in $(cat hosts.txt); do
ssh -o BatchMode=yes -o ConnectTimeout=5 \
-o KexAlgorithms=mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com \
"$h" true 2>&1 | grep -q 'no matching key exchange' && echo "NO-PQ $h"
done
# A non-PQ server answers: Unable to negotiate with ... no matching key
# exchange method found. Their offer: curve25519-sha256,...
| Distribution | OpenSSH shipped | PQ key exchange with a current client | Source |
|---|---|---|---|
| Ubuntu 22.04 LTS | 8.9 | Yes, sntrup761 | ServerPilot version table |
| Ubuntu 24.04 LTS | 9.6 | Yes, sntrup761 | ServerPilot version table |
| Ubuntu 26.04 LTS | 10.2 | Yes, ML-KEM default | Ubuntu package listing |
| Debian 12 bookworm | 9.2 | Yes, sntrup761 | sources.debian.org |
| Debian 13 trixie | 10.0 | Yes, ML-KEM default | sources.debian.org |
| RHEL 10 | 9.9 | Both; ML-KEM preferred from 10.1 | Red Hat, May 2026 |
| Ubuntu 20.04, Debian 11 | 8.2, 8.4 | No, client warns | OpenSSH 8.9 notes |
A distribution's crypto policy or a hand-edited KexAlgorithms line can remove the hybrids, so trust the kex: algorithm line over the table. If you are planning the wider migration (TLS, VPNs, code signing), my post-quantum migration checklist covers the parts that are harder than SSH.
Which SSH key type should you use: Ed25519, RSA or FIDO2?
Ed25519 for software keys. It has been supported since OpenSSH 6.5 in January 2014 and became the ssh-keygen default in 9.5 (October 2023). RSA is still acceptable at 3072 bits or more because modern OpenSSH signs with rsa-sha2-256 or rsa-sha2-512; what died is the SHA-1 ssh-rsa signature, disabled by default in 8.8 (September 2021) after chosen-prefix SHA-1 collisions fell below US$50,000. DSA is gone: on my 10.3p1 client, ssh-keygen -t dsa now answers unknown key type dsa.
For humans, I would go further and use a FIDO2 security key. OpenSSH added the ecdsa-sk and ed25519-sk types in 8.2 (February 2020); the private key file on disk is only a handle, useless without the physical token, and by default every signature needs a touch. Generate with ssh-keygen -t ed25519-sk -O resident -O verify-required so the handle lives on the token and each use needs the PIN. The 10.5 and 10.6 releases kept polishing this path: the client now tries touch-free keys before PIN-protected ones, and ssh-add preserves the PIN requirement when loading resident keys.
What about the new ML-DSA hybrid keys that 10.6 enabled? I would wait. The scheme comes from an Internet-Draft (draft-miller-sshm-mldsa44-ed25519-composite-sigs, per the 10.4 notes), keys from the 10.4 experimental implementation must be regenerated, and the signature threat is not retroactive. Revisit when your distribution ships it and every other SSH implementation you depend on, including your Git host, accepts it.
SSH certificates vs authorized_keys: when should you switch?
An authorized_keys file is a per-user, per-host list of trusted keys with no expiry. Offboarding means editing every host, and nobody does. SSH certificates invert that: a certificate authority signs a user's public key with a list of principals (allowed usernames) and a validity window, and servers trust the CA through TrustedUserCAKeys. With eight-hour certificates, revocation is mostly a matter of not issuing tomorrow's. The mechanics are built into ssh-keygen; this is a real run on 10.3p1:
# CA signs a user key: identity, principals, 8-hour validity, no port forwarding
ssh-keygen -s user_ca -I "ajay@example.com" -n ajay,deploy -V +8h \
-O no-port-forwarding id_ed25519.pub
# Signed user key id_ed25519-cert.pub: id "ajay@example.com" serial 0 for ajay,deploy
# valid from 2026-10-24T15:50:00 to 2026-10-24T23:51:15
ssh-keygen -L -f id_ed25519-cert.pub
id_ed25519-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:nr4jtB/n/y7b6BMFO3gEj3zY9d2/4drarPGASlUfkkI
Signing CA: ED25519 SHA256:C/ephfsFd0+KO98psuBd709qw4Nn2J4WtfGj26QmpI4 (using ssh-ed25519)
Key ID: "ajay@example.com"
Serial: 0
Valid: from 2026-10-24T15:50:00 to 2026-10-24T23:51:15
Principals:
ajay
deploy
Critical Options: (none)
Extensions:
permit-X11-forwarding
permit-agent-forwarding
permit-pty
permit-user-rc
Note what is missing from the extensions: permit-port-forwarding, removed by -O no-port-forwarding. On the server, TrustedUserCAKeys plus an AuthorizedPrincipalsFile per account means a new engineer needs a certificate, not a line in every authorized_keys file.
Two 2026 fixes are worth knowing if you already run a CA. The 10.3 change above means a mis-issued certificate with no principals is now useless rather than a skeleton key, and 10.6 fixed a daylight-saving bug in ssh-keygen date handling that could produce expiry times off by an hour. Managed options exist too: Google's OS Login accepts SSH certificate authentication and ties access to IAM roles. Certificates are also the cleanest way to give an AI agent or CI job shell access: short validity, a single principal, restrict or a forced command, and a key ID that shows up in every log line. I made the longer argument for that in non-human identity in 2026.
A hardened sshd_config for OpenSSH 10
This is the drop-in I would start from on a 9.9 or newer server. I validated it with sshd -t on OpenSSH 10.3p1; every directive is in the current sshd_config manual.
# /etc/ssh/sshd_config.d/00-hardening.conf
# Debian and Ubuntu read sshd_config.d/ near the top of sshd_config, and for
# each keyword the FIRST value read wins, so a low-numbered drop-in beats later lines.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
# Ed25519, FIDO2 and RSA-SHA2 only: no SHA-1 ssh-rsa, no plain ECDSA
PubkeyAcceptedAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256
# Post-quantum hybrids first; curve25519 kept for clients older than 8.9
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256
# Short-lived user certificates, mapped to allowed principals per account
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile /etc/ssh/principals/%u
# FIDO keys must prove a PIN; the man page says this has no effect on non-FIDO keys
PubkeyAuthOptions verify-required
MaxAuthTries 3
LoginGraceTime 30
AllowGroups ssh-users
# Remove on jump hosts: ProxyJump needs TCP forwarding
DisableForwarding yes
# Logs the key fingerprint on failures too
LogLevel VERBOSE
Apply it with sudo sshd -t && sudo systemctl reload ssh (the unit is sshd on RHEL-family systems), and keep your current session open until a second one logs in. Check the effective result with sudo sshd -T -C user=ajay,host=laptop,addr=198.51.100.7, which evaluates Match blocks for that connection. The "first value wins" rule is a common cause of lockouts, and my Permission denied (publickey) guide walks through debugging it. For basic brute-force noise you may not need fail2ban any more: since 9.8, PerSourcePenalties is on by default, and sshd -T on 10.3 shows the defaults as 5 seconds for a failed authentication, 90 for a crash, 5 for an invalid user, capped at 600.
Bastion host vs SSM Session Manager vs Tailscale SSH
The real best practice is that port 22 should not face the internet at all. Every major cloud now has an identity-aware path that removes the need for a self-managed jump host:
| Option | Inbound 22 from internet | Who authenticates | Documented cost | Limits worth knowing |
|---|---|---|---|---|
| Self-managed bastion | Yes, on the bastion | sshd keys or certificates | The instance and its public IPv4 | You patch it, log it and watch it |
| AWS SSM Session Manager | No inbound ports | IAM | No charge on EC2; $0.05 per session on hybrid nodes from 30 Sept 2026 | Session logging is not available for SSH or port-forwarding sessions |
| EC2 Instance Connect Endpoint | No public IP needed | IAM, then your SSH key | No additional cost; cross-AZ data billed | 5 per Region, 1 per VPC, 20 concurrent; 1-hour connections |
| GCP IAP TCP forwarding + OS Login | Only from 35.235.240.0/20 | Google identity and IAM roles | Not stated on the docs page | Metadata SSH keys are ignored once OS Login is on |
| Tailscale SSH | No public port | Tailnet identity over WireGuard | Per Tailscale plan | Server side Linux and macOS tailscaled; check mode re-auth every 12 hours by default |
Two details from the docs change how I use these. AWS states that Session Manager cannot record sessions that tunnel SSH through it, because it only sees encrypted bytes; if you need a transcript, use a native shell session or record on the host. And an EC2 Instance Connect Endpoint connection outlives the IAM credentials that opened it, up to the one-hour cap, so set maxTunnelDuration in the policy rather than trusting short credentials to end sessions.
What regreSSHion and the xz backdoor taught us about sshd
regreSSHion (CVE-2024-6387, July 2024) was a signal-handler race in sshd on glibc Linux that allowed unauthenticated remote code execution as root. It was a regression: a 2006 bug reintroduced in October 2020, present from 8.5p1 to 9.7p1. Qualys counted over 14 million potentially vulnerable internet-facing instances through Censys and Shodan. The OpenSSH 9.8 notes said exploitation took 6 to 8 hours of connections on average against 32-bit systems, and the stopgap for anyone who could not patch, LoginGraceTime 0, traded the RCE for an easy denial of service.
The xz backdoor, found by Andres Freund in March 2024, never touched OpenSSH's code. It sat in xz 5.6.0 and 5.6.1 and reached sshd because several distributions patch it to notify systemd, and libsystemd links liblzma. OpenSSH 9.8 responded with a standalone systemd notification implementation that does not depend on libsystemd.
Neither lesson is about cryptography. Both are about knowing which sshd build runs on every host, what it links, and how fast a fix reaches it. That is configuration management, inventory and unattended patching, the boring platform work that matters more now that OpenSSH says AI tools are surfacing bugs faster than before. Keep sshd_config in Git, render it from one template, test it with sshd -t in CI, and alert on any host whose ssh -V lags the distribution's current security update.
FAQ
Should I change the SSH port from 22?
It reduces log noise from scanners, but it is not a security control. Disabling passwords, using keys or certificates, and keeping 22 off the internet behind SSM, IAP or a tailnet does far more. On Ubuntu with socket activation, a port change also needs systemctl daemon-reload (Launchpad bug 2069041).
Is Ed25519 more secure than RSA for SSH?
Both are fine at sensible sizes; Ed25519 is faster, has tiny keys, and avoids RSA's padding and key-size choices, which is why it has been the ssh-keygen default since OpenSSH 9.5. Use RSA of 3072 bits or more only when a legacy system cannot take Ed25519, and never with SHA-1 ssh-rsa signatures.
Is OpenSSH post-quantum by default?
Yes. Key exchange has been a post-quantum hybrid by default since OpenSSH 9.0, and since 10.0 the default is mlkem768x25519-sha256. Run ssh -v host exit 2>&1 | grep 'kex: algorithm' to confirm a given connection; servers older than 8.9 will negotiate a classical exchange and trigger a warning on 10.1+ clients.
Do I still need a bastion host in 2026?
Usually not. AWS Session Manager and EC2 Instance Connect Endpoint, Google IAP with OS Login, Azure Bastion and Tailscale SSH all give identity-checked access without exposing port 22. Keep a bastion only where you need plain OpenSSH semantics across networks you control, and treat it as the most patched host you own.
This quarter I would do five things: run the PQ-only audit loop above and upgrade anything that fails it; move human logins to FIDO2 ed25519-sk keys with PIN verification; issue short-lived certificates for people, CI and agents instead of adding lines to authorized_keys; ship the drop-in above through configuration management with an sshd -t gate; and close port 22 from the internet in favour of whichever identity-aware proxy your cloud already provides. None of it is new technology, which is the point.
Related: Post-Quantum Migration in 2026, Secrets Sprawl 2026 and Workload Identity Federation Explained.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Fix SSH Permission Denied (publickey): Causes and Fixes (2026)
Fix SSH 'Permission denied (publickey)' step by step: read ssh -vvv, use the right user (ec2-user, ubuntu, admin, git), fix key and authorized_keys permissions, IdentitiesOnly and agent issues, sshd -T and Match blocks, ssh-rsa and DSA errors, SELinux, and EC2, GCP and Azure keys.
15 minOct 31, 2026Top Cloud Security Misconfigurations in 2026: 59% Still Have Old Keys
The cloud security misconfigurations that still matter in 2026, with current data: 59% of IAM users hold year-old keys, half of EC2 skips IMDSv2, 1% of S3 buckets are public, and the DBIR puts third parties in 48% of breaches.
13 minOct 20, 2026TLS Certificate Lifetimes in 2026: 200 Days Now, 47 Days by 2029
CA/Browser Forum ballot SC-081 cut public TLS certificates to 200 days on 15 March 2026, then 100 days in 2027 and 47 days in 2029. What changed, what Let's Encrypt did (no OCSP, 6-day certs, ACME profiles, 45 days), and how ARI keeps renewals safe.
11 min