Skip to content
Security Advanced Tutorial

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.

Emeka Okafor
Emeka Okafor
Security Editor · Jul 10, 2026 · 10 min read
Issue Short-Lived SSH Certificates with HashiCorp Vault (No More Static Keys)

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_TOKEN set, with a token that has admin rights on the sys/mounts and ssh-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-keygen locally (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_ttl are 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_users is a comma-separated allowlist of OS usernames the cert can claim. If you want per-person accountability instead of shared ubuntu/deploy logins, set allowed_users_template = true and use allowed_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: ca is 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, or TrustedUserCAKeys points to the wrong path/has wrong permissions. Check sudo sshd -T | grep trustedusercakeys to confirm the running config.
  • certificate invalid: name is not a listed principal: the valid_principals you requested at sign time doesn't match allowed_users on 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 grant update on ssh-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 Okafor
Written by
Emeka Okafor · Security Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading