Issue Short-Lived SSH Certificates with HashiCorp Vault (No More Static Keys)
Set up Vault's SSH secrets engine as a certificate authority so developers get auto-expiring SSH certs instead of long-lived keys sitting in authorized_keys files.
What you'll build
You'll configure Vault's SSH secrets engine as a certificate authority, sign a developer's ephemeral SSH keypair into a certificate with a 30-minute TTL, and set up a target server to trust that CA instead of individual authorized_keys entries. End state: no long-lived private key is ever copied to a server, and every session key is auto-revoked by expiry.
Prerequisites
- Vault 1.13+ (this uses stable SSH secrets engine behavior; nothing here is version-fragile, but test on your actual version before rolling to prod)
- Vault CLI installed and
VAULT_ADDR/VAULT_TOKENset, with a token that has admin rights on thesys/mountsandssh-client-signer/*paths for setup - A Linux target server running OpenSSH 6.9+ (for certificate support) — Ubuntu 20.04/22.04 or Amazon Linux 2 both qualify
- Root or sudo access on the target server to edit
sshd_config ssh-keygenlocally (OpenSSH client, any recent version)- Some existing Vault auth method (OIDC, LDAP, userpass) so developers can log in as themselves — this tutorial assumes you already have one configured and just references it
This walkthrough covers client certificate signing (Vault vouches for the user connecting), not host certificate signing (Vault vouching for the server). Do both in production; host certs kill SSH's "trust on first connect" TOFU prompt and prevent MITM against your servers. That's a good follow-up, not covered here.
1. Enable the SSH secrets engine
Mount it at a path that describes its role. Don't reuse the default ssh/ path if you'll eventually also run host-cert signing under a separate mount.
vault secrets enable -path=ssh-client-signer ssh
2. Generate the CA signing key
Vault generates and stores the CA private key internally. It never leaves Vault's storage backend.
vault write ssh-client-signer/config/ca generate_signing_key=true
Grab the public half, you'll need it on every server:
vault read -field=public_key ssh-client-signer/config/ca > trusted-user-ca-keys.pem
If you ever need to rotate the CA (compromise, key hygiene policy), re-run step 2 with generate_signing_key=true again, but plan a rollout window since old certs signed by the previous key will still validate until they expire, and new servers need the new public key before you cut over.
3. Create a signing role
The role defines who can be signed as, for how long, and what SSH extensions (agent forwarding, PTY, etc.) get granted.
vault write ssh-client-signer/roles/developer-role -<<"EOH"
{
"algorithm_signer": "rsa-sha2-256",
"allow_user_certificates": true,
"allowed_users": "ubuntu,ec2-user,deploy",
"allowed_extensions": "permit-pty,permit-port-forwarding,permit-agent-forwarding",
"default_extensions": {
"permit-pty": ""
},
"key_type": "ca",
"default_user": "ubuntu",
"ttl": "30m",
"max_ttl": "1h"
}
EOH
A few things worth understanding here, not just copying:
ttl/max_ttlare the whole point of this exercise. 30 minutes default, 1 hour hard cap. Tune to your risk tolerance, but don't go past a few hours for interactive developer access.allowed_usersis a comma-separated allowlist of OS usernames the cert can claim. If you want per-person accountability instead of sharedubuntu/deploylogins, setallowed_users_template = trueand useallowed_users = "{{identity.entity.name}}", then create matching OS accounts. That's the setup you want for audit trails, but it adds account provisioning overhead, so decide upfront.key_type: cais what makes Vault sign a certificate rather than hand back a raw key. Don't confuse this engine mode with Vault's OTP mode (key_type: otp), which is a completely different mechanism (one-time password injected via PAM) and doesn't use certificates at all.
4. Lock down who can request signatures
Create a Vault policy scoped to just the sign path, and attach it to whatever auth method your developers use (OIDC group, LDAP group, etc.).
vault policy write ssh-developer - <<EOF
path "ssh-client-signer/sign/developer-role" {
capabilities = ["update"]
}
EOF
Without this, anyone with a Vault token could sign themselves into developer-role, defeating the purpose of an internal CA.
5. Configure the target server to trust the CA
Copy trusted-user-ca-keys.pem to every server (Ansible, Puppet, whatever you already use for config management, don't do this by hand at scale):
sudo cp trusted-user-ca-keys.pem /etc/ssh/trusted-user-ca-keys.pem
sudo chmod 644 /etc/ssh/trusted-user-ca-keys.pem
Add this line to /etc/ssh/sshd_config:
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem
Reload sshd (don't full-restart on a box you're currently SSH'd into if you can avoid it, reload keeps existing sessions alive):
sudo systemctl reload sshd
If you're using the allowed_users_template approach with per-person principals, also add AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u and populate that directory to map cert principals to local accounts. Skip this if you're using shared static usernames from step 3.
6. Developer login flow
This is what a developer runs, ideally wrapped in a script or shell function so nobody has to remember the raw commands.
# authenticate to Vault first (example: OIDC)
vault login -method=oidc
# generate a throwaway keypair, no passphrase needed since it's short-lived
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_vault -N "" -C "vault-ephemeral-$(date +%s)" -q
# get it signed
vault write -field=signed_key ssh-client-signer/sign/developer-role \
public_key=@$HOME/.ssh/id_ed25519_vault.pub \
valid_principals=ubuntu \
> ~/.ssh/id_ed25519_vault-cert.pub
chmod 600 ~/.ssh/id_ed25519_vault
Note the $HOME there instead of ~. Bash and zsh only expand a bare ~ at the start of a word, not when it's glued to another character like @. Vault's CLI won't expand it for you either, so public_key=@~/.ssh/... fails with a literal "no such file or directory" on a path starting with a tilde. $HOME is a plain variable, so it expands correctly no matter what precedes it.
OpenSSH automatically picks up a file named <key>-cert.pub sitting next to <key>, so you just SSH normally:
ssh -i ~/.ssh/id_ed25519_vault ubuntu@target-server.internal
No authorized_keys entry required on the server for this user, ever. When the cert expires, that keypair is dead weight, delete it or let the next login overwrite it.
Verify it works
Inspect the signed certificate before or after connecting:
ssh-keygen -L -f ~/.ssh/id_ed25519_vault-cert.pub
You should see Type: user certificate, the Principals: list matching what you requested, and a Valid: window roughly matching your role's ttl. Confirm login succeeds, then wait past the TTL and try again with the same (now-expired) cert. It should fail with Certificate invalid: expired. That failure is the feature working correctly, not a bug.
Troubleshooting
Permission denied (publickey)with no cert error shown: sshd probably didn't reload, orTrustedUserCAKeyspoints to the wrong path/has wrong permissions. Checksudo sshd -T | grep trustedusercakeysto confirm the running config.certificate invalid: name is not a listed principal: thevalid_principalsyou requested at sign time doesn't matchallowed_userson the role, or doesn't match the OS username you're logging in as. Principals and usernames both need to line up.certificate has expired: either your TTL genuinely lapsed (expected), or there's clock skew between your laptop and Vault/server. Check NTP sync on all three.* is not authorized to sign: your Vault token's policy doesn't grantupdateonssh-client-signer/sign/<role>. Re-check the policy attachment on your auth method, not just the policy document itself.
Next steps
Add host certificate signing next (allow_host_certificates in a separate role) so servers present certs too, eliminating the SSH host-key-verification prompt and closing the MITM gap this tutorial leaves open. Look at Vault Agent or a wrapper CLI to automate the keygen-and-sign dance transparently on every ssh invocation. If you're running fleets in AWS, Vault's SSH engine pairs well with short-lived IAM auth for the Vault login step itself, removing static credentials at every layer, not just SSH.
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.