Replacing Distributed SSH Keys with a Vault Certificate Authority
Signing short-lived SSH certificates on demand instead of distributing public keys.
The problem
The usual way to grant someone SSH access to a fleet of hosts is to append their public
key to authorized_keys on every host they need, or to push it there via configuration
management. This works for a handful of hosts and falls apart as the fleet and the team
grow: revoking access means finding and editing every host that key was ever pushed to,
rotating a compromised key means the same exercise in reverse, and there is no audit trail
beyond whatever the config management tool happened to log — authorized_keys itself does
not record who connected, only who is allowed to try.
The alternative is to stop distributing keys at all. An SSH certificate authority signs a short-lived certificate over a user’s public key; hosts are configured once to trust the CA’s public key, and after that, access is a signing operation rather than a file distribution. Revoking access is “stop signing for this person” rather than “find every host”. A short TTL means a certificate that leaks is only useful for minutes, not until someone remembers to remove it.
Vault’s ssh secrets engine can act as that CA. It is easy to under-use, though, because
setting it up looks deceptively similar to generating a normal keypair, and it is easy to
miss the two things that actually make it a fleet-wide access-control mechanism rather than
just a different way to make a key: the certificate’s principals (who it is valid for)
and its TTL (how long it is valid), both enforced by Vault at signing time, not by the
host.
Working through it
Why one CA key beats N distributed keys
A host only ever needs to trust one thing: the CA’s public key, installed once via
TrustedUserCAKeys. Every certificate that CA signs is automatically trusted by every host
configured that way, with no further file distribution. Access control moves entirely into
Vault: who can request a signature, for which allowed_users, and for how long, all
governed by a Vault policy and role rather than by what happens to be sitting in a file on
a hundred hosts.
Standing up the CA and telling a host to trust it
The ssh secrets engine, mounted and told to generate its own signing key, is the whole
CA:
vault secrets enable -path=ssh-client-signer ssh
vault write ssh-client-signer/config/ca generate_signing_key=true
vault read -field=public_key ssh-client-signer/config/ca > trusted-user-ca-keys.pem
A host trusts it by pointing sshd at that public key:
# /etc/ssh/sshd_config.d/10-ca.conf
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem
Nothing user-specific has happened yet — the host now trusts a CA, not any particular person. Who that CA is willing to vouch for is decided entirely on the Vault side, by the role used to sign.
Defining who can be whom, and for how long
A role is where the actual access policy lives:
vault write ssh-client-signer/roles/demo-role -<<EOF
{
"allow_user_certificates": true,
"allowed_users": "demo",
"default_extensions": {
"permit-pty": ""
},
"key_type": "ca",
"default_user": "demo",
"ttl": "30m",
"max_ttl": "1h"
}
EOF
allowed_users constrains which OS usernames a certificate from this role may claim as a
principal — a certificate cannot be signed for a user this role does not permit, regardless
of who is asking, unless a Vault policy also restricts who may use the role at all (which,
in a real deployment, is exactly how different teams get access to different roles). ttl
and max_ttl are enforced by Vault when it signs, not by the host, so shortening them takes
effect immediately for every future signature with no host-side change.
Signing on demand from the client side
A user generates an ordinary keypair — Vault never sees or stores the private key — and asks Vault to sign the public half:
ssh-keygen -t ed25519 -f client_key -N "" -C "demo-client"
vault write -field=signed_key ssh-client-signer/sign/demo-role \
public_key=@client_key.pub valid_principals=demo > client_key-cert.pub
ssh-keygen -Lf client_key-cert.pub shows exactly what was granted — principals, validity
window, and extensions — which is worth inspecting the first time, since it is the thing
that actually governs access, not the keypair itself.
The solution
A complete, disposable demonstration: an sshd container configured to trust the CA, and a client certificate signed by Vault used to log in without ever placing a public key on the container.
# 1. Start a dev Vault server (development only — never for real secrets).
vault server -dev -dev-root-token-id=root -dev-listen-address=127.0.0.1:8200 &
export VAULT_ADDR=http://127.0.0.1:8200
export VAULT_TOKEN=root
# 2. Stand up the CA.
vault secrets enable -path=ssh-client-signer ssh
vault write ssh-client-signer/config/ca generate_signing_key=true
vault read -field=public_key ssh-client-signer/config/ca > ca.pub
# 3. Define who it will vouch for.
vault write ssh-client-signer/roles/demo-role -<<EOF
{
"allow_user_certificates": true,
"allowed_users": "demo",
"default_extensions": { "permit-pty": "" },
"key_type": "ca",
"default_user": "demo",
"ttl": "30m",
"max_ttl": "1h"
}
EOF
# Dockerfile
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash demo
RUN mkdir -p /run/sshd
COPY sshd_config.d/10-ca.conf /etc/ssh/sshd_config.d/10-ca.conf
COPY ca.pub /etc/ssh/trusted-user-ca-keys.pem
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]
# sshd_config.d/10-ca.conf
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem
PasswordAuthentication no
PubkeyAuthentication yes
# 4. Build and run the target host. No user key is ever copied into it.
docker build -t ssh-ca-demo .
docker run -d --name ssh-ca-demo -p 2222:22 ssh-ca-demo
# 5. Generate a client keypair and have Vault sign it.
ssh-keygen -t ed25519 -f client_key -N "" -C "demo-client"
vault write -field=signed_key ssh-client-signer/sign/demo-role \
public_key=@client_key.pub valid_principals=demo > client_key-cert.pub
# 6. Log in using the certificate, not a distributed key.
ssh -p 2222 -i client_key -i client_key-cert.pub \
-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no \
-o IdentitiesOnly=yes -o PreferredAuthentications=publickey \
demo@127.0.0.1 'whoami; echo CERT_LOGIN_OK'
Expected output:
demo
CERT_LOGIN_OK
-o IdentitiesOnly=yes is not cosmetic here: without it, an SSH client with other default
identity files present (or an ssh-agent holding unrelated keys) will offer those first,
and sshd’s MaxAuthTries can be exhausted before it ever tries the certificate,
producing a confusing “Too many authentication failures” rather than a clear rejection.
UserKnownHostsFile=/dev/null and StrictHostKeyChecking=no are demonstration shortcuts
for a throwaway container with an ephemeral host key — a real deployment should also stand
up an SSH host CA (ssh-host-signer, the same pattern applied to host keys instead of user
keys) so clients can verify the host without disabling the check.
Waiting past the certificate’s TTL and repeating the same ssh command demonstrates the
other half of the mechanism: the certificate is rejected on expiry with no host-side change
required to revoke it.
Conclusion
Moving from distributed keys to a CA does not remove the need for an access-control decision, it relocates it: instead of “which hosts have this key in authorized_keys”, the question becomes “which Vault policy can use which role”, which is one place to look rather than every host in the fleet.
Short TTLs are the actual revocation mechanism in this model — there is no host-side “remove this certificate” operation, only “wait for it to expire” or “rotate the CA key”, so the TTL is a security control, not a convenience setting, and should be chosen accordingly rather than left at whatever default felt reasonable during setup.
This does not remove the CA itself as a target: whoever can request signatures from a
permissive role effectively has the access that role grants, so the Vault policy guarding
ssh-client-signer/sign/* deserves the same scrutiny as the authorized_keys files it is
replacing.