SSH Certificate Authorities: Ditching authorized_keys
Short-lived signed certificates instead of a key file you forget to prune

Contents
I found a key in an authorized_keys file a while back that belonged to a laptop I’d sold two years earlier. Nothing had gone wrong — the buyer wasn’t lurking around my network — but the fact that it could have gone wrong, silently, for two years, is the actual problem with authorized_keys as an access model. Every box you manage ends up with its own copy of that file, every key you’ve ever generated is scattered across some subset of them, and revoking access to a departed key means finding and editing every single copy. Across a handful of homelab machines that’s tedious. Across a few dozen it’s the kind of thing that just doesn’t happen, and the stale key sits there indefinitely.
SSH certificate authorities fix the actual architecture problem underneath the symptom. Instead of every server trusting a list of individual public keys, every server trusts one thing: a CA public key. You sign short-lived certificates with the CA’s private key, and any server configured to trust that CA accepts anyone presenting a valid signed certificate, full stop — no per-server key list to maintain, and because the certificates expire on their own, a compromised or forgotten key stops being usable within hours or days without anyone having to remember to revoke it.
Why this is a genuinely different model
authorized_keys is a whitelist you maintain by hand, per machine, forever. Every new laptop, every new team member if you ever have one, every key rotation means touching every server that person needs access to. And critically, revocation is the same problem in reverse — deleting access requires finding every place that access was granted, which is exactly the kind of bookkeeping that silently rots as an environment grows.
A CA-based model inverts the trust relationship. The server doesn’t ask “is this specific public key on my list” — it asks “is this certificate signed by a CA I trust, and has it expired.” You configure that trust once per server (a single line pointing at the CA’s public key), and after that, granting or revoking a person’s access is entirely a CA-side operation: you either sign them a new certificate or you don’t, and old certificates simply stop working when their validity window closes. There’s no server-side list to prune because there’s nothing sitting around waiting to be revoked — the expiry does that work automatically.
This also solves host key sprawl in the other direction. Every time you SSH to a new box for the first time, you get the “authenticity of host can’t be established” prompt and either blindly accept it (bad habit) or manually verify the fingerprint (tedious at scale). With host certificates, your client trusts the CA once and silently accepts any host certificate signed by it — no more per-host TOFU (trust-on-first-use) prompts, and no more wondering whether that fingerprint change is a reimage or a man-in-the-middle.
It’s worth being precise about what a certificate actually contains, because the model only clicks once you see the shape of the thing. A signed SSH certificate is the original public key plus a structured bundle of metadata: a serial number, the validity window, the principal list, an identity string for logging, and a set of extensions that constrain what the certificate is allowed to do. That whole bundle is then signed by the CA’s private key. The server doesn’t need to have seen the underlying public key before — it verifies the CA’s signature over the bundle, checks the validity window against wall-clock time, and checks the principal against its local mapping. Everything downstream of “trust this one CA key” falls out of that single verification step, which is why adding or removing a hundred users from a fleet of servers never touches the servers at all.
The extensions are the part most people skip past on a first read, and they’re genuinely useful for a homelab. ssh-keygen -s accepts flags like -O no-port-forwarding, -O no-agent-forwarding, -O no-x11-forwarding, or -O force-command="some/specific/script" at signing time, baked directly into the certificate rather than configured server-side. That means you can hand someone a certificate that’s only good for running one specific command, or one that can log in but never open a forwarded port, without touching that server’s sshd_config at all — the restriction travels with the credential. I use this for a couple of automation accounts that only ever need to run a single backup script; their certificates are signed with force-command pointed at that script, so even if the private key leaked, it couldn’t be used for an interactive shell.
Setting up the CA
Generate a dedicated CA keypair — this is the single most sensitive artefact in the whole setup, so treat it accordingly: strong passphrase, stored offline or on hardware you control, never copied onto the servers it signs for.
| |
Two separate CAs — one for signing user certificates (who’s allowed to log in), one for signing host certificates (proving a server is who it claims to be). You can use one key for both, but separating them means a compromise of one doesn’t immediately hand over the other, and it keeps the mental model clean: user CA proves identity of people, host CA proves identity of machines.
Signing a user certificate
Someone (in a homelab, usually just you, on a second machine or a fresh laptop) generates a normal SSH keypair as usual:
| |
They send you the public key only. You sign it with the CA, specifying a short validity window and a principal — a label that servers will check against, roughly analogous to a username or role:
| |
-I is just a human-readable identity string for logs. -n smarc sets the principal — the server will only accept this cert for logins as a user matching that principal. -V +12h caps validity at twelve hours from signing. That’s the whole revocation story: in twelve hours, this certificate is worthless, whether you meant to revoke it or not. For genuinely long-lived automation (a backup job’s key, say) you’d use a longer window, but for interactive human access, short-lived is the entire point.
The result is a new file, id_ed25519-cert.pub, that sits alongside the existing keypair. The SSH client picks it up automatically if it’s named correctly and placed next to the private key.
Configuring servers to trust the CA
On each server, instead of managing authorized_keys per user, add one line telling sshd which CA to trust and which principals map to which local accounts:
| |
| |
Copy ca_user_key.pub (the public half — never the CA private key) to /etc/ssh/ca_user_key.pub on the server, restart sshd, and any certificate signed by that CA with a matching principal in the local account’s principals file is accepted — no authorized_keys entry needed at all for that account.
| |
For host certificates, sign the server’s own host key with the host CA:
| |
The -h flag marks it as a host certificate rather than a user one. Host certs can reasonably run for months rather than hours — the risk profile is different, since a stolen host key doesn’t grant login access, it only lets something impersonate the host, and rotating host keys is more disruptive than rotating a laptop’s user key.
| |
And on the client side, tell it to trust hosts certified by your host CA instead of relying on per-host fingerprint prompts:
| |
Revocation for the cases short expiry doesn’t cover
Short-lived certificates handle the routine case — a departed key just stops working once its window closes, no action required. But there’s a gap: what about a certificate you signed for twelve hours that you need to kill in the next five minutes, because a laptop was just stolen or a credential was pasted somewhere it shouldn’t have been? Waiting out the expiry isn’t good enough for that.
sshd supports an explicit revocation list for exactly this. Maintain a KRL (key revocation list) file and point servers at it:
| |
The -z flag sets a serial or update number so later revocations can be layered in. Reference it in sshd_config:
| |
For a homelab-scale setup this list changes rarely, so a manual scp to each server after generating a new KRL is fine — you don’t need a distribution mechanism as elaborate as the CA itself. The point isn’t that you’ll use this often; it’s that having it wired up in advance means an actual emergency is a two-minute fix instead of a scramble to remember how sshd_config revocation even works while also dealing with whatever prompted the emergency in the first place.
Choosing the validity window itself is a judgement call worth making deliberately rather than copying a number from a blog post. Twelve hours suits a laptop you actively use through a working day and re-sign against each morning; it’s short enough that a lost or stolen laptop stops being a credential within a shift, and long enough that you’re not re-authenticating every time you open a terminal. A phone or tablet you SSH from occasionally might warrant something shorter, since the window a stolen device stays useful matters more than daily convenience. The general principle: validity should track how often you’re willing to re-sign, weighed against how bad it is if that specific certificate falls into the wrong hands during its window. There’s no universal right answer, only a trade-off you get to set per class of device instead of inheriting whatever an authorized_keys file happened to accumulate over the years.
Troubleshooting
“Certificate invalid: expired” and you’re sure you just signed it. Check clock skew between the machine that signed the certificate and the server validating it. Certificate validity windows are checked against wall-clock time on both ends; if the CA machine’s clock is fast or the server’s is slow, a freshly-signed cert can appear expired or not-yet-valid. NTP sync (timedatectl / chrony) fixes this, and it’s worth checking first before assuming the signing step itself went wrong.
Server rejects the certificate even though TrustedUserCAKeys looks right. The most common cause is a principal mismatch — the -n value used when signing doesn’t appear in the server’s AuthorizedPrincipalsFile for that account, or the file path in AuthorizedPrincipalsFile %u doesn’t actually resolve to a file that exists for the login user. Double-check the exact principal string on both sides; it’s case-sensitive and a common typo source.
Client doesn’t seem to use the certificate at all, falls back to plain key auth or fails. The cert file needs to be named <keyname>-cert.pub and live in the same directory as the private key it corresponds to — ssh-keygen -s produces this automatically if you don’t override -O, but a renamed or moved cert file won’t be picked up implicitly. Confirm with ssh -v and look for “Offering public key” versus “Offering certificate” in the verbose output.
Lost the CA private key. There’s no recovery — that’s the nature of holding the trust root. Every server needs its TrustedUserCAKeys/HostCertificate reconfigured to a new CA, and every user needs a freshly signed certificate. This is exactly why the CA key deserves stronger protection (offline storage, a passphrase, ideally a hardware token) than any individual user key ever would — losing one user’s key is an inconvenience, losing the CA is a full re-provisioning exercise.
Automation/CI needs a cert and can’t type a passphrase interactively. Keep a separate, narrowly-scoped signing credential for automated signing — either a CA key with no passphrase kept in a properly access-controlled secrets store, or better, a short-lived signing service that itself authenticates before it’ll sign. Don’t reuse your personal interactive CA key for scripted signing; the blast radius of a leaked automation credential should be as small as you can make it.
Is it worth it
For one or two personal boxes, plain authorized_keys is genuinely fine — the maintenance burden this solves barely exists at that scale, and setting up a CA is more ceremony than the problem warrants. The value shows up once you’re managing enough machines that the “did I remove that old key everywhere” question stops having a confident answer, or once you’ve got other people (even just future-you on a new laptop) needing access without a manual per-server onboarding step each time.
The genuine win is that revocation becomes a non-event — it’s just time passing — rather than an audit you have to remember to run. If you’re already comfortable with normal SSH key authentication and manage more than a handful of hosts, this is the natural next step up, and it pairs well with the rest of a hardened baseline from the SSH hardening guide — password auth off, certs on, and one CA key to worry about instead of a sprawling list of individually-trusted keys.




