FIDO2 SSH with sk-* Keys: Hardware-Backed Authentication for Production Hosts
Problem
Standard SSH keys live on disk. The private key is a file: ~/.ssh/id_ed25519. Anything that can read that file (a backup tool, a misconfigured Slack share, a developer laptop compromise, an rsync mistake) can copy the key. Once copied, the key is indistinguishable from the original — every host that trusts the public key trusts the copy.
Mitigations exist (encrypted private keys with passphrases, ssh-agent forwarding) but each has gaps. A passphrase protects against passive theft of the file but does not stop a compromised process that captures the passphrase from the user’s terminal. Agent forwarding is a known phishing vector and effectively trusts every intermediate hop.
OpenSSH 8.2+ (February 2020) added sk-* key types — Security Key SSH keys — that bind the private key to a hardware token (YubiKey, SoloKey, Token2, Nitrokey, Google Titan, or any FIDO2/U2F device). The private key never exists in unprotected memory or on disk. Each authentication requires the user to physically touch the token, which a remote attacker cannot satisfy.
By 2026 the toolchain is universal:
- OpenSSH 9.x default-supports both
ed25519-skandecdsa-sk(NIST P-256). - Most major Linux distros (Ubuntu 22.04+, RHEL 9+, Debian 12+) ship with
libfido2and the helpers needed. - Hardware tokens are available for $25-$70.
- Cloud providers’ bastion hosts (AWS Session Manager, GCP IAP, Azure Bastion) interoperate with
sk-*keys via standard OpenSSH on the user side.
Despite the maturity, a substantial fraction of production fleets still allow plain ed25519 keys. The migration is mechanically trivial: generate a new sk-* key, add to authorized_keys, deprecate the old key. The friction is operational discipline, not technical.
This article covers sk-* key generation, server-side configuration to require hardware-backed keys, multi-token enrollment patterns, integration with SSH certificate authorities, and the failure modes (token loss, FIDO2 firmware bugs, host compatibility).
Target systems: OpenSSH 9.x server and client, FIDO2-capable hardware tokens (YubiKey 5+, SoloKey 2+, Token2, Nitrokey 3+, Google Titan), Linux 5.4+ with libfido2 support, macOS 13+, Windows 11 + WSL.
Threat Model
- Adversary 1 — Filesystem theft of private key: attacker exfiltrates
~/.ssh/id_ed25519from a compromised laptop, a leaked backup, an open share. Wants to authenticate as the user. - Adversary 2 — Memory scraping: malware running as the user reads ssh-agent’s in-memory key material (or future fork via
ptrace). - Adversary 3 — Phishing-style remote attack: attacker compromises a host the user has agent-forwarded to. The forwarded socket allows the attacker to authenticate elsewhere as the user.
- Adversary 4 — Coerced sign: attacker has physical access to the user’s machine. Wants to sign with the user’s key.
- Adversary 5 — Server-side key trust drift: an old, unrotated key remains in
authorized_keyson legacy hosts long after the key was compromised. - Access level: Adversaries 1-3 are remote / software-only. Adversary 4 has physical access. Adversary 5 is the result of poor key lifecycle.
- Objective: Authenticate as the user against any host that trusts the user’s public key.
- Blast radius: With on-disk keys: every host trusting the key is reachable. With
sk-*keys: zero — without the hardware token, the private key is unrecoverable.
Configuration
Step 1: Generate sk-* Keys
# ed25519-sk requires a FIDO2 token. Insert it before running.
ssh-keygen -t ed25519-sk -O resident -O verify-required \
-O application=ssh:production \
-C "you@laptop, 2026-04-27"
# Tap the token when it blinks.
# Generates ~/.ssh/id_ed25519_sk and ~/.ssh/id_ed25519_sk.pub
Key options:
-O resident— store the key handle on the token itself (rather than only on disk). This lets you recover the credential on a fresh laptop by enumerating the token. Withoutresident, losing the on-disk key handle means losing access.-O verify-required— require user verification (PIN entry, fingerprint) on every authentication. Without this, just a touch is required. For high-trust hosts, require verification.-O application=ssh:production— namespace the credential. Differentapplicationstrings produce different keys on the same hardware token.
The public key file looks like:
sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t... you@laptop, 2026-04-27
The sk- prefix and @openssh.com suffix are the markers that tell the server “this key is hardware-backed.”
Step 2: Enroll on Production Hosts
Standard authorized_keys workflow:
ssh-copy-id -i ~/.ssh/id_ed25519_sk.pub user@host.example.com
Or via your existing key-distribution mechanism (Ansible, Puppet, Vault SSH CA, Teleport).
Step 3: Server-Side: Require Hardware-Backed Keys
OpenSSH server can refuse non-sk-* keys via PubkeyAcceptedAlgorithms:
# /etc/ssh/sshd_config.d/60-fido2-only.conf
PubkeyAcceptedAlgorithms sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com
Reload sshd and verify:
sudo sshd -T | grep pubkeyacceptedalgorithms
# pubkeyacceptedalgorithms sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com
sudo systemctl reload ssh
Existing on-disk keys now fail to authenticate; only hardware-backed keys are accepted.
For phased rollout (allow both during migration):
PubkeyAcceptedAlgorithms sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519
After migration deadline, remove ssh-ed25519 and reload.
Step 4: Require User Verification (PIN / Biometric)
The client verify-required option on key generation can be enforced server-side: the server can demand verify-required keys via PubkeyAuthOptions:
# /etc/ssh/sshd_config.d/60-fido2-verify.conf
PubkeyAuthOptions verify-required
A key generated without -O verify-required will fail authentication against this server. Use for production hosts holding sensitive data; user-verification adds 1-2 seconds of friction per authentication, which is fine for production access but expensive for high-frequency CI use.
Step 5: Enroll Multiple Tokens (Backup)
A single token is a single point of failure. Enroll at least one backup token; some teams enroll three (primary, backup-laptop, recovery-vault).
# Repeat key generation with the second token.
ssh-keygen -t ed25519-sk -O resident -O verify-required \
-O application=ssh:production-backup \
-f ~/.ssh/id_ed25519_sk_backup \
-C "you@laptop-backup, 2026-04-27"
# Append both public keys to authorized_keys on every host.
cat ~/.ssh/id_ed25519_sk.pub ~/.ssh/id_ed25519_sk_backup.pub | \
ssh-copy-id -f -i /dev/stdin user@host
Update each host’s authorized_keys to trust both. Lose one token, the other still authenticates.
Step 6: Integrate with SSH Certificate Authority
For fleets larger than a handful of hosts, individual authorized_keys distribution is unmanageable. Use an SSH CA: a central authority signs short-lived certificates derived from the user’s sk-* key.
# CA signs the user's sk-* public key.
ssh-keygen -s ca_key -I "user-cert-2026-04-27" \
-n alice -V +24h \
-O verify-required \
~/.ssh/id_ed25519_sk.pub
# Produces ~/.ssh/id_ed25519_sk-cert.pub
Hosts trust the CA’s public key (one entry in /etc/ssh/sshd_config):
TrustedUserCAKeys /etc/ssh/ca_user.pub
Now any user with a valid certificate signed by the CA authenticates — regardless of whether the host has the user’s individual key on file. The certificate’s verify-required option propagates to the server, which enforces hardware verification.
For automation, integrate with HashiCorp Vault’s SSH secrets engine or Teleport’s CA, both of which support certificate-based hardware-backed SSH out of the box.
Step 7: Remove Legacy Keys
After migration, audit and purge old keys:
# On each host, list non-sk keys still trusted.
for host in $(cat hosts.txt); do
ssh "$host" "awk '!/sk-ssh-ed25519|sk-ecdsa-sha2-nistp256/{print FILENAME, NR, \$1}' \
/home/*/.ssh/authorized_keys /root/.ssh/authorized_keys 2>/dev/null"
done
# After confirming only legitimate operator keys remain, replace the file:
echo "operator-1-public-key sk-ssh-ed25519..." | ssh "$host" "tee ~/.ssh/authorized_keys"
Keep the old key trust window short (1-2 weeks). The longer it lingers, the more likely a stolen key gets used.
Expected Behaviour
| Signal | On-disk keys | sk-* keys |
|---|---|---|
| Backup containing the key | Authenticatable by anyone with the backup | Useless without the hardware token |
| Phishing / malicious script reads key | Succeeds | Fails (no key on disk to read) |
| User authentication | Type passphrase | Tap or PIN-and-tap on hardware |
| Authentication latency | <100ms | 200-500ms (touch + verify) |
| Key rotation requirement | Periodic | Tied to token replacement (rare) |
| Forwarded agent risk | Compromised hop authenticates as user | Compromised hop cannot generate fresh signature without token |
Verify the protection holds:
# Server log on successful authentication.
sudo journalctl -u ssh | grep "Accepted publickey" | tail -1
# Apr 27 10:00:00 server sshd[1234]: Accepted publickey for alice from 1.2.3.4 port 56789 ssh2:
# ED25519-SK SHA256:AbCdE...
# Negative test: try a stolen-key scenario.
# Copy ~/.ssh/id_ed25519_sk to a different machine.
# Without the original token, ssh attempts fail at signing time:
ssh user@host
# (no token detected) sign_and_send_pubkey: signing failed for ED25519-SK from agent: agent refused operation
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Hardware-backed keys | Defeats key-theft attacks | Token cost ($25-$70 per device); user must carry it | Issue tokens as part of hardware onboarding; bulk-purchase. |
verify-required |
Defeats stolen-token attacks (PIN required) | 1-2 second friction per authentication | Apply to production / sensitive hosts; allow touch-only for low-risk hosts. |
| Multi-token enrollment | Survives token loss | Extra setup per user | Document the recovery flow; second token kept in secure off-laptop location. |
| SSH CA + certificates | Centralized trust; short-lived authority | CA infrastructure to operate | Use Vault, Teleport, or smallstep — managed CA tooling. |
Strict server-side PubkeyAcceptedAlgorithms |
Eliminates legacy key paths | Migration window required for users without tokens yet | Phase rollout: warning logs, then dual-allow, then strict. |
| Audit and purge legacy keys | Closes the trust-drift gap | Operational work to track keys | Use SSH key inventory tools (ssh-audit, ansible facts) to enumerate. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Token lost or broken | User cannot authenticate to anything | User reports inability to access systems | Use the backup token. Generate new credentials with the new token, distribute. The lost token is effectively useless to a finder (still requires PIN). |
| Token firmware bug | Authentication fails or hangs at the touch step | OS logs show libfido2 errors |
Update token firmware via vendor tooling. Keep a backup token of a different vendor / model to mitigate model-specific bugs. |
| Resident key collision | Multiple credentials with same application namespace get confused |
Wrong credential selected on auth attempt | Use distinct application strings (ssh:prod, ssh:staging); token enumerates by application name. |
| Host doesn’t support sk-* (pre-OpenSSH-8.2) | sk-* keys cannot authenticate to legacy hosts | Auth log shows “no matching algorithm” | Upgrade OpenSSH; for hosts that cannot upgrade (vendor appliances), maintain a separate, restricted-use legacy key path with strict access controls. |
verify-required not enforced |
Host accepts touch-only authentication when PIN was expected | Audit log shows authentications without UV flag | Use PubkeyAuthOptions verify-required server-side; log and alert on any authentication without UV. |
| User stuck without backup token | Single-point-of-failure during outage | User unable to access hosts during incident | Always enroll at least one backup. For high-availability accounts (on-call SRE), three tokens distributed across home/laptop/safe. |
| OS doesn’t have libfido2 | Client cannot use sk-* keys | Client-side error: “could not load library libfido2.so” | Install libfido2 (apt install libfido2-1 on Debian / Ubuntu); for older systems, build from source or use a different client machine. |