Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Security

Published:

Tags:
  • ssh
  • pki
  • certificates
  • linux
  • security
  • access-control

How to Set Up SSH Certificate Authentication with Your Own Minimal CA

If you manage more than a handful of servers, you already know the failure mode: someone leaves, and you're grepping through authorized_keys files on forty boxes trying to find every place their key got pasted in. Or someone joins, and onboarding means the same copy-paste ritual in reverse. SSH has had a clean answer to this for over a decade, and almost nobody uses it: certificates.

This isn't x509 and it isn't Let's Encrypt. OpenSSH has its own, much simpler certificate format. A certificate is just a public key plus some signed metadata (who it's valid for, when it expires, what it's allowed to do) with a signature from a CA key that both ends already trust. No CRL infrastructure, no ASN.1, no certificate chains. You can build the whole thing with ssh-keygen and a text editor.

What problem this actually solves

Without certificates, trust is distributed: every server holds a list of every key allowed to log in, and revoking access means editing that list everywhere it appears. With a CA, trust is centralised: every server trusts one CA public key, and access is granted by signing a user's key, not by copying it around. Revoke by not renewing, or by publishing a revocation list. It also fixes the client side of the problem: instead of accepting host keys on trust-on-first-use and hoping nobody MITMs you on day one, clients can trust a host CA and skip the "authenticity of host can't be established" prompt entirely, for every host that CA has signed.

The certificates are short-lived by design (hours or days, not years), which is the actual security win here: a leaked signed key is only useful until it expires, not until someone remembers to remove it.

1. Generate the CA key

The CA key is the whole trust anchor, so treat it like one. Generate it on a machine that isn't one of the servers it will sign access to, ideally offline or on a YubiKey. Ed25519 is the right choice unless you have a specific reason to need RSA for legacy client compatibility.

ssh-keygen -t ed25519 -f user_ca -C "example-user-ca" -N ""
ssh-keygen -t ed25519 -f host_ca -C "example-host-ca" -N ""

That gives you two keypairs: user_ca/user_ca.pub for signing user certificates, and host_ca/host_ca.pub for signing host certificates. Using two separate CAs for these two jobs is deliberate: a host certificate signing key and a user certificate signing key have different blast radii if compromised, and there's no reason to couple them. If you only care about one side (say you just want to stop pasting user keys around and don't care about host trust), skip the one you don't need.

The private CA keys never leave the signing machine. Only the .pub files get distributed to servers.

2. Sign host keys

Each server already has a host key pair in /etc/ssh (usually ssh_host_ed25519_key.pub). Sign it with the host CA:

ssh-keygen -s host_ca -I "web01.internal.example.com" \
  -h -n web01.internal.example.com,web01 \
  -V +52w \
  /etc/ssh/ssh_host_ed25519_key.pub

-h marks this as a host certificate rather than a user certificate. -I is a free-text identity string that shows up in logs, useful for audit. -n is the list of principals, hostnames this certificate is valid for; include every name and alias the host is reachable as, because a mismatch here fails closed. -V sets validity; a year is reasonable for host certs since rotating host keys is more disruptive than rotating user certs, but you can go shorter if your provisioning is automated (worth doing if servers are ephemeral, e.g. autoscaled).

This produces ssh_host_ed25519_key-cert.pub alongside the existing key. Tell sshd about it:

# /etc/ssh/sshd_config
HostKey /etc/ssh/ssh_host_ed25519_key
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub

On the client side, instead of accepting each host key on first connection, tell SSH to trust anything signed by your host CA. Add a line to known_hosts (or a dedicated file referenced via GlobalKnownHostsFile):

@cert-authority *.internal.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... example-host-ca

Now any host in that domain whose certificate is signed by host_ca is trusted automatically, no TOFU prompt, and critically, no silent acceptance of an attacker's host key either. This is arguably the more underrated half of SSH certificates: most guides focus on user auth, but host trust is where TOFU quietly rots into "yes, yes, yes, whatever, connect" muscle memory.

3. Sign user keys

Users generate their own keypair as normal (ssh-keygen -t ed25519), and send you the public key (or better, you have a workflow where they request a cert through some short-lived process, but for a minimal setup, manual signing is fine to start). You sign it with the user CA:

ssh-keygen -s user_ca -I "[email protected]" \
  -n andy,deploy \
  -V +8h \
  -z 42 \
  id_ed25519.pub

-n here lists principals as Unix usernames the certificate can be used to log in as, not hostnames. A certificate with principal deploy can log in as the deploy user on any server configured to trust that principal, regardless of who actually holds the key, so keep this list as tight as the person's actual access needs. -V +8h gives a working-day validity window; short-lived certificates mean a user re-authenticates against whatever issued the cert (an SSO flow, a Vault instance, a manual signing script) at least once a day, which is exactly the point. -z is a serial number, useful for revocation later.

On the server, point sshd at the user CA and specify which principals are acceptable per account:

# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile /etc/ssh/authorized_principals/%u

And in /etc/ssh/authorized_principals/deploy:

deploy

Now anyone holding a certificate with principal deploy, signed by the trusted CA, and not expired, can log in as the deploy user. authorized_keys for that account can be empty or removed entirely.

4. Revocation

Short expiry handles most of the revocation problem by attrition, but sometimes you need to kill access immediately (a laptop is stolen, someone is offboarded mid-shift). OpenSSH supports a KRL (key revocation list) built from serial numbers or key IDs:

ssh-keygen -k -f revoked.krl -s user_ca.pub -z 42

Distribute revoked.krl to servers and reference it:

# /etc/ssh/sshd_config
RevokedKeys /etc/ssh/revoked.krl

The catch, and it's worth being honest about it, is that this is push-based revocation: there's no OCSP-style live check, so a server that hasn't received the updated KRL will still honour a revoked certificate until its natural expiry. This is the actual argument for keeping -V short: a KRL you forgot to push everywhere is a much smaller problem if the certificate would have expired in eight hours anyway.

Verifying what you've actually signed

Before trusting any of this in production, inspect a certificate the way you'd inspect an x509 one:

ssh-keygen -L -f id_ed25519-cert.pub

This prints the key ID, serial, principals, validity window, and signing CA fingerprint. It's the thing to check when a login mysteriously fails: the two most common causes are a principal that doesn't match AuthorizedPrincipalsFile, and a certificate that expired six hours ago because someone's local clock is fine but their sense of time management isn't.

One thing this setup deliberately doesn't give you is anything resembling a full PKI: no intermediate CAs, no cross-signing, no OCSP. For a small to medium fleet that's a feature, not a gap. The complexity budget of x509 exists to solve problems, like independent CAs on the public internet not trusting each other by default, that a private SSH deployment where you control every endpoint simply doesn't have.