Short answer. Run ssh-keygen -t ed25519 -C "you@host-year" and give the key a passphrase; Ed25519 has been the default since OpenSSH 9.5. If you have a FIDO2 security key, prefer -t ed25519-sk (or -t ecdsa-sk when the authenticator lacks Ed25519), so the private key never sits on disk. Use RSA only for systems that cannot do anything else, and then at 3072 bits or more: GitHub refuses smaller new RSA keys since 14 October 2026. Load keys into the agent with a lifetime (ssh-add -t 8h), do not forward the agent to hosts you do not control (use ProxyJump), and constrain keys to destinations with ssh-add -h. For fleets, replace scattered authorized_keys entries with short-lived SSH certificates. Post-quantum protection of the session is already on: OpenSSH 10 negotiates mlkem768x25519-sha256 by default and warns when a server cannot. The new hybrid signature type ssh-mldsa44-ed25519 (OpenSSH 10.6) is worth testing, not yet worth deploying as your only key.
The commands ran in debian:forky-20261005-slim with the Debian packages openssh-client and openssh-server 1:10.6p1-1 on 20 October 2026. All keys were generated for this article.
Which key type
-t | What it is | Use it when |
|---|---|---|
ed25519 (default) | Ed25519, 32-byte public key, deterministic signatures | Almost always |
ed25519-sk, ecdsa-sk | FIDO2 key: the secret stays in the authenticator, every signature needs a touch | You have a security key and the server runs OpenSSH 8.2 or later |
ecdsa | ECDSA on P-256, P-384 or P-521 | A policy requires NIST curves |
rsa | RSA, 3072 bits by default since OpenSSH 8.0 | Old servers, appliances, some HSMs |
mldsa44-ed25519 | Hybrid ML-DSA-44 + Ed25519 signature, new in 10.6 | Testing post-quantum authentication |
DSA is gone: OpenSSH 10.0 (April 2025) removed it, "completing the deprecation process that began in 2015". RSA keys are fine; what was disabled in OpenSSH 8.8 (September 2021) is the ssh-rsa signature algorithm, which hashes with SHA-1. The same RSA key signs with rsa-sha2-256 or rsa-sha2-512, which is why the server error about ssh-rsa is usually fixed by upgrading the client, not by replacing the key. GitHub follows the same logic: it will remove ssh-rsa signatures on 13 January 2027, after brownouts on 4 November and 9 December 2026, and since 14 October 2026 it accepts new RSA keys only from 3072 bits.
Sizes, as written by ssh-keygen 10.6 (the .pub line includes the comment):
ssh-keygen -q -t rsa -N '' -f ~/rsa_default
ssh-keygen -q -t ecdsa -N '' -f ~/ecdsa_default
ssh-keygen -q -t mldsa44-ed25519 -N '' -f ~/.ssh/id_mldsa44_ed25519
for f in ~/rsa_default ~/ecdsa_default ~/.ssh/id_ed25519 ~/.ssh/id_mldsa44_ed25519; do
ssh-keygen -l -f "$f.pub"
printf ' public key line: %s bytes, private key file: %s bytes\n' "$(wc -c < "$f.pub")" "$(wc -c < "$f")"
done
3072 SHA256:NGv36ZHCwauN4GGrdKSDZPnKTn6nFuLSVRS01/eSXOs alice@59151e7fff6c (RSA)
public key line: 572 bytes, private key file: 2602 bytes
256 SHA256:4ODqYFVTGSwjx7pfis5ZtZ02PKaSd15alII7RIHPhE8 alice@59151e7fff6c (ECDSA)
public key line: 180 bytes, private key file: 513 bytes
256 SHA256:I72RDcR38Z+glA2IhaHUj/h844afNwkK9siDEGHv24c alice@laptop-2026 (ED25519)
public key line: 99 bytes, private key file: 464 bytes
512 SHA256:oqHqur1575V136Khl2P7Gaa6rM+2dPT51B6YfjLSxB4 alice@59151e7fff6c (MLDSA44-ED25519)
public key line: 1868 bytes, private key file: 3982 bytes
The hybrid public key line is 19 times the size of an Ed25519 one. That matters for authorized_keys files, certificates and anything that stores keys in a database column sized for Ed25519. The comparison Ed25519 vs RSA covers the classical pair in detail.
Generating a key
ssh-keygen -t ed25519 -C "alice@laptop-2026" -N 'correct horse battery staple' -f ~/.ssh/id_ed25519
Created directory '/home/alice/.ssh'.
Generating public/private ed25519 key pair.
Your identification has been saved in /home/alice/.ssh/id_ed25519
Your public key has been saved in /home/alice/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:I72RDcR38Z+glA2IhaHUj/h844afNwkK9siDEGHv24c alice@laptop-2026
The key's randomart image is:
+--[ED25519 256]--+
| ..o*..... |
| o . .=... =. |
|. o .. +. + o. |
| . . . o =. . ...|
| o + S .. ..|
| . . o + B |
| . * = * o . |
| o E = o.+ |
| o oo. . |
+----[SHA256]-----+
-N passes the passphrase on the command line only because the lab is scripted; interactively, leave it out and ssh-keygen prompts, so the passphrase does not end up in shell history. The comment is free text stored in the public key: a name plus machine plus year makes an authorized_keys audit possible years later. The fingerprint (SHA256:…) is what servers log, so record it when you register the key anywhere.
Passphrase and KDF rounds
Without a passphrase, the private key file is the key: a stolen backup, a laptop image or a misconfigured sync folder hands it over. With one, the file is encrypted with a key derived by bcrypt_pbkdf. The OpenSSH private key format records the cipher, the KDF and its rounds in a header that anyone can read without the passphrase:
"""Print the cipher, KDF and bcrypt rounds from the header of an OpenSSH private key file (openssh-key-v1)."""
import base64
import struct
import sys
text = open(sys.argv[1]).read()
body = "".join(line for line in text.splitlines() if not line.startswith("-----"))
blob = base64.b64decode(body)
magic = b"openssh-key-v1\0"
assert blob.startswith(magic), "not an OpenSSH private key"
pos = len(magic)
def string():
global pos
(length,) = struct.unpack(">I", blob[pos : pos + 4])
value = blob[pos + 4 : pos + 4 + length]
pos += 4 + length
return value
cipher = string().decode()
kdf = string().decode()
options = string()
line = f"cipher={cipher} kdf={kdf}"
if kdf == "bcrypt":
(salt_len,) = struct.unpack(">I", options[:4])
(rounds,) = struct.unpack(">I", options[4 + salt_len : 8 + salt_len])
line += f" salt={salt_len} bytes rounds={rounds}"
print(line)
python3 kdfinfo.py ~/.ssh/id_ed25519
for r in 32 100 500; do
ssh-keygen -q -p -a $r -P 'correct horse battery staple' -N 'correct horse battery staple' -f ~/.ssh/id_ed25519 >/dev/null
s=$(date +%s.%N); ssh-keygen -y -P 'correct horse battery staple' -f ~/.ssh/id_ed25519 >/dev/null; e=$(date +%s.%N)
printf 'rounds=%s unlock=%.2fs\n' $r $(echo "$e - $s" | bc)
done
cipher=aes256-ctr kdf=bcrypt salt=16 bytes rounds=32
rounds=32 unlock=0.16s
rounds=100 unlock=0.52s
rounds=500 unlock=2.59s
The default was 16 rounds until OpenSSH 9.4, 24 from 9.4 and is 32 since 10.6. The cost is linear in the rounds, as the 10.6 release notes point out ("not like bcrypt(3) which is exponential"), so the numbers above scale predictably: pick the largest value whose unlock delay you accept, because an attacker who copied the file pays the same price per guess. ssh-keygen -p -a 100 -f ~/.ssh/id_ed25519 re-encrypts an existing key; loading it into the agent once per session means you pay the delay once. Since 10.6, OpenSSH refuses keys with more than a million rounds, so a hostile key file cannot stall a service that parses it.
A passphrase protects the file at rest. It does nothing once the key is decrypted in an agent on a compromised machine, which is where hardware keys come in.
Hardware-backed keys: -sk
ed25519-sk and ecdsa-sk keys (OpenSSH 8.2 and later) keep the secret in a FIDO2 authenticator such as a YubiKey or a platform authenticator. The file ~/.ssh/id_ed25519_sk then holds only a key handle that the authenticator needs to reconstruct the key; without the device it is useless. Every signature requires user presence (a touch), and with -O verify-required also a PIN. The options that matter, quoted from ssh-keygen(1):
-O residentstores the key handle on the authenticator, sossh-keygen -Korssh-add -Kcan load it on another computer. The manual warns that this "increases the likelihood of an attacker being able to use a stolen authenticator device", so combine it with a PIN.-O verify-requiredrequires user verification (currently a PIN) for each signature; the server can demand it with theverify-requiredoption inauthorized_keys.-O application=ssh:workseparates several resident keys on one device; the string must begin withssh:.-O no-touch-requireddrops the touch, and sshd refuses such signatures unlessauthorized_keysexplicitly allows them.-O write-attestation=pathsaves the authenticator's attestation, which lets an organization prove at enrollment that the key was created on approved hardware.
ssh-keygen -t ed25519-sk -O resident -O verify-required -O application=ssh:work -C "alice@yubikey-2026"
We did not run this one: the container has no authenticator, and a transcript without one would only show the error. Some older authenticators implement only P-256; use ecdsa-sk for those. Platform keys work too, but keys held by a laptop's secure hardware have their own trade-offs, covered in hardware-backed keys.
Post-quantum: key exchange now, signatures later
The two halves of SSH have different deadlines. The session keys come from the key exchange; if it is broken later, recorded traffic can be decrypted (harvest now, decrypt later). Host and user signatures only matter at the moment of connection: a future quantum computer cannot go back and forge a login that already happened. OpenSSH therefore switched the key exchange first:
sntrup761x25519-sha512has been on by default since OpenSSH 9.0 (2022);mlkem768x25519-sha256, ML-KEM-768 combined with X25519, is the default since OpenSSH 10.0 (April 2025) and is now specified in RFC 10042 (Informational, 2026);- since 10.1 the client prints a warning when the session falls back to a classical exchange, and since 10.6 the server logs it.
debug1: kex: algorithm: mlkem768x25519-sha256
debug1: kex: host key algorithm: ssh-ed25519
debug1: Server host key: ssh-ed25519 SHA256:Qw4tNHnCo8UwS2qNXROrEm8RP+d+ccaVbwAzv8lLX+I
debug1: Offering public key: /home/alice/.ssh/id_ed25519 ED25519 SHA256:I72RDcR38Z+glA2IhaHUj/h844afNwkK9siDEGHv24c agent
debug1: Server accepts key: /home/alice/.ssh/id_ed25519 ED25519 SHA256:I72RDcR38Z+glA2IhaHUj/h844afNwkK9siDEGHv24c agent
Authenticated to localhost ([::1]:22) using "publickey".
Against a server restricted to KexAlgorithms curve25519-sha256,ecdh-sha2-nistp256, the default client printed:
** 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
Two details we observed: the client stays silent when you restrict KexAlgorithms to a classical method (the warning targets servers), and the server logs every such session, for example connection from ::1 port 38814 is not using a post-quantum key exchange algorithm: "curve25519-sha256" [preauth]. WarnWeakCrypto no-pq-kex inside a Match host block silences the client for a host you cannot fix, as openssh.com/pq.html suggests. GitHub announced mlkem768x25519-sha256 for github.com from 14 October 2026; on 20 October our 10.6 client negotiated it with git@github.com.
Signatures are the second step. OpenSSH 10.4 added an experimental composite of ML-DSA-44 and Ed25519; 10.6 enabled it under the name ssh-mldsa44-ed25519, specified in the SSHM working group draft draft-ietf-sshm-composite-sigs. Keys made with the experimental 10.4 code must be regenerated. In the Debian 10.6 package, ssh-keygen -A created ssh_host_mldsa44_ed25519_key next to the RSA, ECDSA and Ed25519 host keys, and the type sits at the end of the default HostKeyAlgorithms, so clients still pick Ed25519. Asking for it explicitly works end to end:
debug1: kex: host key algorithm: ssh-mldsa44-ed25519
debug1: Server host key: ssh-mldsa44-ed25519 SHA256:UVOUu+csMH8i8Zd/oWzokAGfzRYFetOncVzB4vYeMgQ
debug1: Server accepts key: /home/alice/.ssh/id_mldsa44_ed25519 MLDSA44-ED25519 SHA256:oqHqur1575V136Khl2P7Gaa6rM+2dPT51B6YfjLSxB4 explicit
Authenticated to localhost ([::1]:22) using "publickey".
Both halves sign the same domain-separated message, and "Failure of either verification operation MUST cause the composite signature verification to fail", so the hybrid is no weaker than Ed25519 today. Its cost is size (a 2,484-byte signature per authentication, per the draft) and compatibility: servers, forges, bastions and libraries that do not know the type will not accept the key. Generate one, test it against the hosts you run, and keep your Ed25519 key registered alongside.
The agent and agent forwarding
ssh-agent holds decrypted keys and signs on request; programs reach it through the socket in SSH_AUTH_SOCK. Since OpenSSH 10.1 that socket lives under ~/.ssh/agent rather than /tmp. Add keys with a lifetime so a forgotten session does not keep them unlocked forever:
eval "$(ssh-agent -s)" >/dev/null
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK"
ssh-add -t 8h ~/.ssh/id_ed25519
ssh-add -l
SSH_AUTH_SOCK=/home/alice/.ssh/agent/s.eaeHp0xOjD.local.660.o5M944i4eW
Identity added: /home/alice/.ssh/id_ed25519 (alice@laptop-2026)
Lifetime set to 08:00:00
256 SHA256:I72RDcR38Z+glA2IhaHUj/h844afNwkK9siDEGHv24c alice@laptop-2026 (ED25519)
AddKeysToAgent in ~/.ssh/config does the same on first use and accepts a lifetime (AddKeysToAgent 8h) or confirm, which asks before every signature, like ssh-add -c.
Agent forwarding (ssh -A, ForwardAgent yes) exposes that socket on the remote host. ssh_config(5) states the risk plainly: "An attacker cannot obtain key material from the agent, however they can perform operations on the keys". Anyone with root on the remote host can log in as you anywhere your keys work, for as long as you stay connected. Two better habits:
- Jump, do not forward.
ssh -J bastion target(orProxyJump bastionin the config) runs the second connection from your machine through a forwarded TCP stream; the bastion never sees your agent. - Constrain keys.
ssh-add -h(OpenSSH 8.9 and later) binds a key to destinations identified by their host keys fromknown_hosts, and to hops (bastion>target) when you do forward.
ssh-add -h prod-host ~/agent_only_key
ssh -o BatchMode=yes prod-host 'echo signed in on prod-host'
ssh -v -o BatchMode=yes build-host true 2>&1 | grep -iE 'agent|Offering|Permission denied|Authenticated|identity'
Identity added: /home/alice/agent_only_key (alice@laptop)
signed in on prod-host
debug1: get_agent_identities: bound agent to hostkey
debug1: get_agent_identities: ssh_fetch_identitylist: agent contains no identities
debug1: Offering public key: /home/alice/.ssh/id_ed25519 ED25519 SHA256:MA2nEhBnMlCkX3pWWgPtff90dRZF9D9k8i2svCUK1II
alice@127.0.0.2: Permission denied (publickey,password).
The agent bound the session to build-host's host key and then listed no identities for it: the key exists, but not for that destination. The manual is careful about the limit: a constraint "does not prevent an attacker with access to a remote SSH_AUTH_SOCK from forwarding it again and using it on a different host (but only to a permitted destination)".
Certificates instead of authorized_keys
Copying public keys into authorized_keys on every server does not scale and makes revocation a search problem. An SSH certificate is a public key signed by a CA key with an identity, principals, a serial and a validity window. Servers trust the CA once (TrustedUserCAKeys) and check a revocation list (RevokedKeys):
ssh-keygen -s /etc/ssh/user_ca -I "alice@example.com 2026-10-20" -n alice -V +8h -z 1042 /home/alice/.ssh/id_cert_user.pub
ssh-keygen -L -f /home/alice/.ssh/id_cert_user-cert.pub
su - alice -c 'ssh -o IdentitiesOnly=yes -o IdentityAgent=none -i ~/.ssh/id_cert_user localhost "echo logged in with certificate"'
echo "serial: 1042" > /tmp/revoke.txt
ssh-keygen -k -u -s /etc/ssh/user_ca.pub -f /etc/ssh/revoked.krl /tmp/revoke.txt
ssh-keygen -Q -f /etc/ssh/revoked.krl /home/alice/.ssh/id_cert_user-cert.pub
su - alice -c 'ssh -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -i ~/.ssh/id_cert_user localhost true'
Signed user key /home/alice/.ssh/id_cert_user-cert.pub: id "alice@example.com 2026-10-20" serial 1042 for alice valid from 2026-10-20T14:33:00 to 2026-10-20T22:34:33
/home/alice/.ssh/id_cert_user-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:eVstR5lK6VBztjl2AYyWcv8C6xwNahEEVSrQpe7Y/LE
Signing CA: ED25519 SHA256:+URM6AMiGUu+gv/gxoQgpOhHP4CUN1fznXWDlVxorbY (using ssh-ed25519)
Key ID: "alice@example.com 2026-10-20"
Serial: 1042
Valid: from 2026-10-20T14:33:00 to 2026-10-20T22:34:33
Principals:
alice
Critical Options: (none)
Extensions:
permit-X11-forwarding
permit-agent-forwarding
permit-port-forwarding
permit-pty
permit-user-rc
logged in with certificate
Revoking from /tmp/revoke.txt
/home/alice/.ssh/id_cert_user-cert.pub (alice@ci-runner): REVOKED
alice@localhost: Permission denied (publickey).
sshd logged key ED25519-CERT SHA256:eVst… revoked by file /etc/ssh/revoked.krl. Note the default extensions: a certificate permits agent and port forwarding unless you sign with -O clear and add back only what is needed (-O permit-pty). Keep the CA key offline or in an HSM, issue certificates for hours rather than years, and short lifetimes make most revocations unnecessary. Host certificates (-h when signing, @cert-authority in known_hosts) remove the trust-on-first-use prompt the same way.
Rotating keys
- User keys. Generate the new key, add its public key next to the old one everywhere (servers, forges, CI secrets), connect with
-i new_key -o IdentitiesOnly=yesto prove it works, then remove the old line. A dated comment tells you which line is old. - Host keys.
UpdateHostKeyshas been on by default since OpenSSH 8.5 (unless you overrideUserKnownHostsFileor enableVerifyHostKeyDNS): after authentication the server announces all its host keys and the client records new ones, so a server can publish a replacement before retiring the old key. Clients that connect rarely miss the window; host certificates avoid the problem. - Algorithms. The same mechanism carries a migration: add an Ed25519 (and, later, a hybrid) host key, let clients learn it, then remove the RSA key.
- Inventory.
ssh-keygen -l -f authorized_keysprints one fingerprint per line; compare it with sshd'sAccepted publickey … SHA256:log lines to find keys nobody uses.
Common mistakes
- A world-readable key file.
sshrefuses to use it and prints WARNING: UNPROTECTED PRIVATE KEY FILE!.chmod 600the key and700the.sshdirectory. - The wrong passphrase or a converted key. incorrect passphrase supplied to decrypt private key and error in libcrypto look similar; the second usually means the file is damaged or in a format the build cannot read. Libraries need the passphrase explicitly, as Go's this private key is passphrase protected shows.
- Too many keys in the agent. sshd gives up after
MaxAuthTries(6) failed attempts and disconnects withToo many authentication failures, and each key the client tries can use one up (OpenSSH 10.6 servers allow six extra "would you accept this key" queries,PubkeyOptions max-pk-ok). SetIdentityFileandIdentitiesOnly yesper host. If no offered key is registered, you get Permission denied (publickey). - Agent refuses. agent refused operation means the server accepted the key but the agent would not sign: a FIDO key not touched in time, a confirmation it cannot display, a locked token, or a key file on disk that no longer matches what the agent holds.
- Old peers. no matching host key type found. Their offer: ssh-rsa means the server can only sign with SHA-1. Upgrade it; a per-host
HostKeyAlgorithms +ssh-rsais a temporary exception, never a global setting. - Ignoring a changed host key. REMOTE HOST IDENTIFICATION HAS CHANGED is either a planned rotation you can confirm out of band or exactly the attack the check exists for. Remove the old line with
ssh-keygen -R hostonly after you know which.
Checklist
ssh-keygen -t ed25519(or-t ed25519-skwith a security key), a passphrase, a comment with owner, machine and year.- Raise the KDF rounds to the delay you accept (
-a 100costs about half a second here). - One key per device; never copy private keys between machines.
- Agent with lifetimes;
IdentitiesOnly yesper host;ProxyJumpinstead ofForwardAgent;ssh-add -hwhen forwarding is unavoidable. - Fleets: user and host certificates with short validity, CA key offline, a KRL on every server.
- Check
ssh -vforkex: algorithm: mlkem768x25519-sha256; treat the post-quantum warning as a server upgrade ticket. - Test
mldsa44-ed25519keys against your own servers; keep a classical key registered until every peer accepts the hybrid.
Sources
- OpenSSH release notes: 8.5 (UpdateHostKeys default), 8.8 (RSA/SHA-1 disabled), 8.9 (destination constraints), 9.0 (sntrup761x25519), 9.4 and 10.6 (KDF rounds), 9.5 (Ed25519 default), 10.0 (ML-KEM default, DSA removed), 10.1 (warning, agent sockets), 10.4 and 10.6 (ML-DSA-44 + Ed25519)
- OpenSSH: post-quantum cryptography
- Manual pages: ssh-keygen(1), ssh-add(1), ssh_config(5)
- RFC 10042: PQ/T hybrid key exchange with ML-KEM in SSH
- draft-ietf-sshm-composite-sigs: post-quantum composite signatures in SSH
- GitHub changelog: security improvements for SSH (22 September 2026)