Skip to content
ecdsa.com
← All articles

ssh-keygen in 2026: key types, passphrases and agents

OpenSSH 10.0 to 10.6 changed more defaults than most people noticed: post-quantum key exchange, a hybrid ML-DSA signature type, more KDF rounds, agent sockets in your home directory. Every command below was run in OpenSSH 10.6p1 and the output is the real one.

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

-tWhat it isUse it when
ed25519 (default)Ed25519, 32-byte public key, deterministic signaturesAlmost always
ed25519-sk, ecdsa-skFIDO2 key: the secret stays in the authenticator, every signature needs a touchYou have a security key and the server runs OpenSSH 8.2 or later
ecdsaECDSA on P-256, P-384 or P-521A policy requires NIST curves
rsaRSA, 3072 bits by default since OpenSSH 8.0Old servers, appliances, some HSMs
mldsa44-ed25519Hybrid ML-DSA-44 + Ed25519 signature, new in 10.6Testing 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):

Default RSA and ECDSA keys, the hybrid, and the Ed25519 key from the next section
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
Output (OpenSSH 10.6p1)
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

Generate an Ed25519 key with a comment that says where it lives
ssh-keygen -t ed25519 -C "alice@laptop-2026" -N 'correct horse battery staple' -f ~/.ssh/id_ed25519
Output (OpenSSH 10.6p1)
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:

kdfinfo.py: read cipher, KDF and rounds from an OpenSSH private key
"""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)
Default rounds, then the cost of unlocking at 32, 100 and 500 rounds
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
Output (one arm64 core in Docker on an Apple M4 Max)
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 resident stores the key handle on the authenticator, so ssh-keygen -K or ssh-add -K can 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-required requires user verification (currently a PIN) for each signature; the server can demand it with the verify-required option in authorized_keys.
  • -O application=ssh:work separates several resident keys on one device; the string must begin with ssh:.
  • -O no-touch-required drops the touch, and sshd refuses such signatures unless authorized_keys explicitly allows them.
  • -O write-attestation=path saves the authenticator's attestation, which lets an organization prove at enrollment that the key was created on approved hardware.
Typical enrollment of a resident, PIN-protected key (needs a FIDO2 authenticator)
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-sha512 has 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.
ssh -v localhost true, default settings on both sides (filtered with grep)
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:

Client output when the server offers no post-quantum key exchange
** 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:

ssh -v -o HostKeyAlgorithms=ssh-mldsa44-ed25519 -o IdentitiesOnly=yes -i ~/.ssh/id_mldsa44_ed25519 (filtered)
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:

Start an agent and add the key for eight hours (the lab feeds the passphrase through SSH_ASKPASS)
eval "$(ssh-agent -s)" >/dev/null
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK"
ssh-add -t 8h ~/.ssh/id_ed25519
ssh-add -l
Output
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 (or ProxyJump bastion in 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 from known_hosts, and to hops (bastion>target) when you do forward.
dest.sh (excerpt): a key that only works for prod-host
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'
Output (shortened: the identity file probing lines are omitted)
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):

Sign a user key for 8 hours, log in, revoke it by serial, try again (run as root on the lab server)
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'
Output (sshd runs with TrustedUserCAKeys /etc/ssh/user_ca.pub and RevokedKeys /etc/ssh/revoked.krl)
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=yes to prove it works, then remove the old line. A dated comment tells you which line is old.
  • Host keys. UpdateHostKeys has been on by default since OpenSSH 8.5 (unless you override UserKnownHostsFile or enable VerifyHostKeyDNS): 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_keys prints one fingerprint per line; compare it with sshd's Accepted publickey … SHA256: log lines to find keys nobody uses.

Common mistakes

  • A world-readable key file. ssh refuses to use it and prints WARNING: UNPROTECTED PRIVATE KEY FILE!. chmod 600 the key and 700 the .ssh directory.
  • 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 with Too 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). Set IdentityFile and IdentitiesOnly yes per 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-rsa is 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 host only after you know which.

Checklist

  1. ssh-keygen -t ed25519 (or -t ed25519-sk with a security key), a passphrase, a comment with owner, machine and year.
  2. Raise the KDF rounds to the delay you accept (-a 100 costs about half a second here).
  3. One key per device; never copy private keys between machines.
  4. Agent with lifetimes; IdentitiesOnly yes per host; ProxyJump instead of ForwardAgent; ssh-add -h when forwarding is unavoidable.
  5. Fleets: user and host certificates with short validity, CA key offline, a KRL on every server.
  6. Check ssh -v for kex: algorithm: mlkem768x25519-sha256; treat the post-quantum warning as a server upgrade ticket.
  7. Test mldsa44-ed25519 keys against your own servers; keep a classical key registered until every peer accepts the hybrid.

Sources