How SSH certificate authorities actually work
Insight · 12 minute read
The problem nobody schedules time to fix
Here is a question worth asking at your next standup: which servers would still accept a login from the last person who left the team?
Almost nobody can answer it. The honest answer requires SSHing into every host and reading authorized_keys, and that only covers the hosts you remember. If you have ever used a configuration management tool to push keys, it covers the hosts still in that inventory — which is not the same set.
This is not negligence. It is the predictable result of the data model. A line in authorized_keys is a public key and an optional comment. That is all. It carries:
- no expiry
- no identity beyond a string anyone can set
- no record of who added it, or when, or why
- no way to revoke it except editing the file on every machine that has it
A credential with no expiry and no owner is not an access control. It is a door that was propped open once and never closed.
Certificates have been the answer since 2010
OpenSSH 5.4 shipped certificate authentication in March 2010. It is in every OpenSSH you are running. It is not a fork, not a patch, and not a third-party daemon.
An SSH certificate is a public key plus signed metadata: who it belongs to, which accounts it may log in as, when it becomes valid, when it stops being valid, and what it is allowed to do. A certificate authority signs that bundle. Hosts are configured to trust the CA, and from then on they will accept any certificate it signed, without ever having seen that user's key before.
The important word is stops. Expiry is in the credential itself. Nobody has to remember to clean up.
The whole thing, concretely
Make a CA
ssh-keygen -t ed25519 -f ssh_user_ca -C "acme user CA"Two files. ssh_user_ca.pub is the public half that hosts will trust. ssh_user_ca is the private half that signs certificates, and it is now the most sensitive file you own — anyone with it can mint a credential for any account on any host that trusts it. Treat it accordingly.
Tell hosts to trust it
Copy the public key to each host and add one line to sshd_config:
TrustedUserCAKeys /etc/ssh/ssh_user_ca.pubThat is the entire host-side setup. From now on, any certificate signed by that CA is a valid credential on this machine.
Which should make you slightly uncomfortable, and correctly so. We will come back to it.
Sign a certificate
ssh-keygen -s ssh_user_ca \
-I "jdoe@acme" \
-n deploy \
-V +8h \
id_ed25519.pub2
3
4
5
-s— the CA private key doing the signing-I— the key ID, which is what lands in the host's auth log. Put something identifying here; it is your audit trail.-n— the principals: the Unix accounts this certificate may log in as. This is the important flag and we will come back to it too.-V— the validity window.+8hmeans it stops working in eight hours, whatever anyone does or forgets.
You get id_ed25519-cert.pub. Inspect it:
ssh-keygen -L -f id_ed25519-cert.pubType: [email protected] user certificate
Public key: ED25519-CERT SHA256:9Xk2...
Signing CA: ED25519 SHA256:pQ7m... (using ssh-ed25519)
Key ID: "jdoe@acme"
Serial: 0
Valid: from 2026-09-20T09:14:00 to 2026-09-20T17:14:00
Principals:
deploy
Critical Options: (none)
Extensions:
permit-pty2
3
4
5
6
7
8
9
10
11
Everything an access decision needs, in the credential, signed.
Use it
Put the certificate next to its private key and connect. The client offers it automatically:
ssh deploy@web-prodNo authorized_keys entry on the host. None was ever created.
Principals are the part people get wrong
-n deploy says this certificate may log in as deploy. Not root. Not ubuntu. Not "whatever account exists."
This is the mechanism that makes certificates better than keys rather than merely more convenient. A key in ~deploy/.ssh/authorized_keys grants the deploy account. A certificate grants exactly the principals named in it, and you can issue a different one tomorrow with different principals, without touching the host.
The common mistake is signing everything with -n root because it always works. If you do that you have rebuilt authorized_keys with extra steps.
You can also restrict further with critical options:
ssh-keygen -s ssh_user_ca -I "jdoe@acme" -n deploy -V +8h \
-O source-address=10.4.0.0/16 \
-O no-port-forwarding \
id_ed25519.pub2
3
4
source-address is genuinely useful: a leaked certificate is worthless from outside your network.
The revocation problem
Here is the part the tutorials skip.
Certificates expire, which handles the slow case. But what about right now — someone is dismissed at 10am and their certificate is valid until 6pm?
OpenSSH's built-in answer is a key revocation list:
ssh-keygen -k -f revoked_keys -s ssh_user_ca id_ed25519-cert.pubRevokedKeys /etc/ssh/revoked_keysThis works, and it has an obvious problem: the file has to get to every host. You are back to distributing state to every machine, which is the thing certificates were supposed to stop. A KRL that is three hours stale is a revocation that has not happened.
The other approach is to ask, at connection time:
AuthorizedPrincipalsCommand /usr/local/sbin/check-principals %u %s
AuthorizedPrincipalsCommandUser nobody2
sshd runs this command on every incoming connection and reads the allowed principals from its stdout. %u is the account being logged into, %s is the certificate's contents. The command can do whatever it likes — including call an API and ask whether this certificate is still good.
Now revocation is immediate. Mark the access revoked centrally, and the next connection attempt returns no principals and is refused. Nothing had to be distributed anywhere.
The trade-off is real and you should make it deliberately: you have put a network call in your SSH connection path, and if that service is unreachable you have to decide whether to fail open (available, insecure) or fail closed (secure, and nobody gets in during an outage). Most people should fail closed and keep a break-glass path — a console, an out-of-band account — that does not depend on the service being up.
Note also AuthorizedPrincipalsCommandUser nobody. OpenSSH refuses to run that command as a privileged user, which is correct and occasionally surprising when your script needs permissions it does not have.
Host certificates, while you are here
The same machinery fixes the other half. This, on a first connection:
The authenticity of host 'web-prod' can't be established.
ED25519 key fingerprint is SHA256:...
Are you sure you want to continue connecting?2
3
Nobody verifies this. Everyone types yes. It is a security control that has been trained out of existence.
Sign your hosts' keys with a host CA:
ssh-keygen -s ssh_host_ca -I web-prod -h -n web-prod.acme.com -V +52w \
/etc/ssh/ssh_host_ed25519_key.pub2
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pubAnd on clients, in known_hosts:
@cert-authority *.acme.com ssh-ed25519 AAAAC3Nz...The prompt stops appearing, because the client can now verify the host properly instead of asking a human to. Rebuilt a server? Sign its new key. No known_hosts warnings, no ssh-keygen -R.
What this costs you
Certificates are strictly better than static keys, but they are not free:
- The CA private key is now your crown jewel. Anyone who has it can sign anything. It should live on a machine you treat as your most sensitive, ideally encrypted at rest, ideally in an HSM if your risk profile justifies it.
- You need something to run the signing. Somebody has to decide who gets a certificate, for what, for how long. That is a policy problem, and it is where the actual work is.
- Rotating the CA is disruptive. Every host trusting the old key has to be updated. Plan it before you need it.
- Short lifetimes mean frequent signing, which means the signing has to be automated, which means a service.
That last point is why most teams read a tutorial like this, agree it is obviously correct, and keep using authorized_keys. The primitives are easy. The operational layer around them is the work.
Where to start
You do not need to solve all of it this quarter. In order of value:
- Host certificates first. Lowest risk, immediate benefit, no change to how anyone logs in. It only removes a warning everyone was ignoring.
- A CA for user access on one non-critical environment. Sign eight-hour certificates by hand if you have to. You will learn what your real constraints are.
- Automate the signing once you know what your policy actually is — not before, because you will encode the wrong policy.
- Revocation last, when expiry alone is no longer sufficient.
The thing to internalise is not any particular tool. It is that access should be a credential with an expiry date, and that a line in a file on thirty servers is not that.
I maintain Shellius, which is one way of doing step 3 — an AGPL, self-hosted CA with approval workflows and an audit trail. It is not the only way, and the principles above are the part that matters.