Passkeys for DevOps: Eliminating SSH Keys via WebAuthn



Replace standing SSH keys with passkeys and short-lived OpenSSH certificates. WebAuthn proves identity; CA-minted certs grant ephemeral access. 


Standing SSH keys solved passwords and then created a durable inventory problem: keys on laptops, keys copied to bastions, keys left after offboarding, and weak binding between a key file and a living identity. Passkeys (WebAuthn / FIDO2) change the human authentication plane. Instead of distributing long-lived public keys to every host, engineers prove presence with a phishing-resistant authenticator to an access portal. That portal mints a short-lived OpenSSH user certificate. Servers trust a Certificate Authority—not personal key sprawl.

The production pattern is not “WebAuthn frames inside SSH.” It is:

  1. WebAuthn for identity proof
  2. OpenSSH certificates for host access
  3. Minutes-scale TTL so stolen artifacts die quickly


Failure Modes in Naive Passkey → SSH Designs


  1. Origin / RP ID mistakes in local CLI helpers
    CLI tools often open a loopback callback such as http://localhost:8085/callback. If the helper accepts assertions for the wrong origin or fails to pin the Relying Party ID, phishing or confused-deputy flows can satisfy the helper with an assertion minted in the wrong security context. Enforce exact rpId and expected origin values such as https://sso.internal.domain.
  2. Ignoring authenticator signCount
    Authenticators increment a signature counter on assertions. If the backend accepts signCount_new <= signCount_old (when a previous non-zero counter exists), cloned authenticators or replayed material can mint certificates. Persist and strictly validate counters.
  3. Long-lived certificate files on disk
    A certificate written to ~/.ssh/id_ed25519-cert.pub remains useful to anyone who can read it until expiry. Multi-hour TTL recreates a smaller version of the standing-key problem. Prefer 5–15 minutes for human interactive access and load into ssh-agent with matching lifetime when possible.
  4. CA private key on the portal host disk
    If the SSH user CA private key lives as a file in the web portal process environment, portal compromise becomes offline arbitrary certificate minting. Keep the CA key in an HSM or KMS/Vault transit-style signer so the application only requests signatures.



Target Architecture


[ Engineer + Passkey ]
        │ WebAuthn assertion
        ▼
[ Access Portal ]
  - verify origin / rpId / challenge / signature
  - validate signCount
  - authorize principals / environment
        │ sign request
        ▼
[ HSM / KMS-backed SSH User CA ]
        │ short-lived user certificate
        ▼
[ SSH agent / CLI ]
        │ certificate auth
        ▼
[ sshd TrustedUserCAKeys ]


Hosts remain simple: trust the user CA public key, optionally map principals dynamically, and reject expired certificates automatically.



WebAuthn Verification Essentials


A registered credential stores at least:

  • credential ID
  • public key
  • current signCount
  • user identity binding
  • transports / authenticator policy flags as required
On authentication, verify:

  • challenge freshness
  • expected origin and RP ID
  • signature over authenticator data and client data hash
  • user presence / user verification flags according to policy
  • signCount monotonicity when the authenticator provides counters
WebAuthn signature verification conceptually checks a signature over:

                                                                   M=A∥SHA-256⁡(C)


where (A) is authenticatorData and (C) is clientDataJSON. For ECDSA P-256 authenticators, verification follows standard ECDSA validation against the registered public key. Implementation should use a mature WebAuthn library rather than hand-rolled crypto. See the W3C WebAuthn specification and Passkeys resource site.



Certificate Envelope 

An OpenSSH user certificate wraps the user’s public key with metadata signed by the CA:

                                                          Tvalid after≤Tnow≤Tvalid before

Policy example for interactive human access:

                                           Î”T=Tvalid before−Tvalid after≤300–900 seconds

Backdate ValidAfter by a small skew buffer (for example 30 seconds) so minor clock drift does not reject fresh certificates. Keep host clocks synchronized anyway.



Production-Oriented Minting Service (Go Sketch)


The following sketch shows certificate minting controls and a strict counter gate. In production, WebAuthn verification should call a full library (challenge store, COSE key verify, flag checks). The CA private key should be used through KMS/HSM, not as a raw in-process file if you can avoid it.


package main

import (
    "crypto/ed25519"
    "crypto/rand"
    "errors"
    "fmt"
    "time"

    "golang.org/x/crypto/ssh"
)

type WebAuthnAssertionPayload struct {
    UserEmail         string
    CredentialID      []byte
    AuthenticatorData []byte
    ClientDataJSON    []byte
    Signature         []byte
    SignCount         uint32
    ExpectedOrigin    string
    ExpectedRPID      string
}

type SSHCertificateSigner struct {
    caSigner ssh.Signer
}

func NewSSHCertificateSigner(caPrivateKey ed25519.PrivateKey) (*SSHCertificateSigner, error) {
    signer, err := ssh.NewSignerFromKey(caPrivateKey)
    if err != nil {
        return nil, fmt.Errorf("failed to create SSH CA signer: %w", err)
    }
    return &SSHCertificateSigner{caSigner: signer}, nil
}

func (s *SSHCertificateSigner) MintUserCertificate(
    userPublicKey ssh.PublicKey,
    identity string,
    allowedPrincipals []string,
    ttl time.Duration,
) (*ssh.Certificate, error) {
    if ttl > 15*time.Minute {
        return nil, errors.New("TTL exceeds maximum policy limit of 15 minutes")
    }

    now := uint64(time.Now().Unix())
    expires := now + uint64(ttl.Seconds())

    cert := &ssh.Certificate{
        Key:             userPublicKey,
        Serial:          uint64(time.Now().UnixNano()),
        CertType:        ssh.UserCert,
        KeyId:           fmt.Sprintf("webauthn-cert-%s-%d", identity, now),
        ValidPrincipals: allowedPrincipals,
        ValidAfter:      now - 30, // skew buffer
        ValidBefore:     expires,
        Permissions: ssh.Permissions{
            Extensions: map[string]string{
                "permit-pty": "",
            },
        },
    }

    if err := cert.SignCert(rand.Reader, s.caSigner); err != nil {
        return nil, fmt.Errorf("failed to sign SSH certificate: %w", err)
    }
    return cert, nil
}

func VerifyAssertionAndMint(
    assertion WebAuthnAssertionPayload,
    lastKnownSignCount uint32,
    clientPublicKey ssh.PublicKey,
    signer *SSHCertificateSigner,
) (string, error) {
    // Cloned authenticator / replay guard
    if lastKnownSignCount > 0 && assertion.SignCount <= lastKnownSignCount {
        return "", fmt.Errorf(
            "possible cloned authenticator: signCount %d <= lastKnown %d",
            assertion.SignCount, lastKnownSignCount,
        )
    }

    // Origin pin for browser ceremonies; CLI helpers must pin equivalently
    if assertion.ExpectedOrigin != "https://sso.internal.domain" {
        return "", errors.New("invalid WebAuthn assertion origin")
    }
    if assertion.ExpectedRPID != "sso.internal.domain" {
        return "", errors.New("invalid WebAuthn rpId")
    }

    // TODO: full WebAuthn cryptographic verification happens here

    principals := []string{"engineer", assertion.UserEmail}
    cert, err := signer.MintUserCertificate(
        clientPublicKey,
        assertion.UserEmail,
        principals,
        5*time.Minute,
    )
    if err != nil {
        return "", err
    }

    return string(ssh.MarshalAuthorizedKey(cert)), nil
}


Host trust remains standard OpenSSH CA configuration (TrustedUserCAKeys). See ssh-keygen certificates and sshd_config.


Passkeys for DevOps: Eliminating SSH Keys via WebAuthn




CLI UX Without Weakening Security


A good CLI helper:

  • opens the fixed corporate origin only
  • never accepts arbitrary redirect targets
  • receives only an authorization code or one-time mint token over loopback, not “any assertion JSON from anywhere”
  • stores certificates in memory/agent where possible
  • sets agent constraints and TTLs aligned with certificate expiry
If a certificate must touch disk, protect the directory permissions and keep TTL short enough that theft has limited value.



Human Access vs Automation


Passkeys are for people. CI and robots should use workload identity, machine certificates, or OIDC federation—not a shared “service passkey.” Crossing those planes recreates key inventory under a different name.



Operational Controls


  • Require user verification (PIN/biometrics) for production principals.
  • Register at least two authenticators for privileged users, plus a controlled recovery process.
  • Log every mint: user, credential ID, signCount, principals, TTL, source IP, target environment.
  • Alert on counter regressions and repeated origin mismatches.
  • Separate user CA and host CA.
  • Prefer KMS/HSM-backed signing APIs over disk-resident CA keys.


Migration Path


  1. Deploy user CA trust on SSH hosts.
  2. Launch passkey registration in the access portal.
  3. Mint short-lived certificates after WebAuthn auth.
  4. Move humans off standing authorized_keys entries.
  5. Retain dual-control break-glass only for emergencies.


Trade-offs


  • Browser-mediated auth adds a step versus local private keys.
  • Portal/CA availability becomes critical path for new sessions.
  • Authenticator loss requires recovery planning.
  • Very short TTLs need smooth re-mint UX or users route around the system.


Final Notes


Eliminating SSH keys for DevOps does not eliminate SSH. It eliminates durable personal keys as the primary trust object. Passkeys provide phishing-resistant human authentication; OpenSSH certificates convert that proof into host-native, time-bounded access. With origin pinning, signCount enforcement, minute-scale certificate TTL, and HSM/KMS-backed CA keys, the organization replaces standing key inventory with auditable issuance events.That is the operational win: access becomes something you mint, not something you permanently install.


Post a Comment

Previous Post Next Post