tutorials6 min read

SSH the Grown-Up Way: Certificate Auth, No Passwords, Revocable Access

Your servers trust one CA public key instead of a pile of authorized_keys files — and every credential expires on its own. OpenSSH has had this since 5.4; here's how to actually turn it on.

The short version

Two CA key pairs (user + host), one signed cert per person with a short validity window and explicit principals, TrustedUserCAKeys on every server — and you can revoke access before expiry via KRLs instead of editing authorized_keys on ten boxes. Verified against the OpenSSH man pages current at review time; commands exercised on OpenSSH 9.9p1 (~1-2 hours for a 3-10 host lab, keep one break-glass static key per host while you flip switches).

Key sprawl dies and access becomes revocable. Right now, every person or deploy script that touches your lab has a line in authorized_keys on every server they can reach — and removing one means SSHing into each box to edit the file. With OpenSSH certificate auth, servers trust one CA public key instead of a pile of user keys, and every credential is a signed certificate with an explicit validity window that expires on its own. It's been in OpenSSH since 5.4 — most homelabs just never turned it on.

Before you start//

  • Any Linux hosts running OpenSSH (certificates supported since 5.4). This procedure was verified against the current sshd_config(5) and ssh-keygen(1) man pages as published at review time (page dates Sep 2 / Aug 7, 2026), and every command below was exercised on OpenSSH 9.9p1 — if you're older than that, ssh -V first; anything ≥5.4 has the feature set used here.
  • Comfort with the command line. ~1–2 hours for a 3–10 host lab.
  • Keep one break-glass static key per host in authorized_keys until you've verified cert-only login works — then remove it. Don't flip switches and pray.

Step 1: Generate two CA key pairs on a trusted box//

ssh-keygen -t ed25519 -f user_ca # signs USER keys (people, deploy scripts) ssh-keygen -t ed25519 -f host_ca # signs HOST keys (your servers' identities)

Why two CAs: separate trust roots so compromising one doesn't compromise both directions — a stolen user_ca lets an attacker log in anywhere; a stolen host_ca lets an attacker impersonate your servers. Store the private keys offline or in your vault: each CA key is access to everything that trusts it. (The public halves — user_ca.pub, host_ca.pub — are what get distributed.)

Step 2: Sign each server's host key, retire the fingerprint prompts//

On a trusted box, for each server:

ssh-keygen -s host_ca -I <hostname> -h -n <hostname>,<fqdn> -V +52w /etc/ssh/ssh_host_ed25519_key.pub

That produces ssh_host_ed25519_key-cert.pub. Install it next to the host key, then on the server set it in sshd_config(5):

HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub

On every client, add one line to ~/.ssh/known_hosts:

@cert-authority *.yourlab.example <contents of host_ca.pub>

Why: the -h flag makes it a host certificate, -n binds it to the exact hostnames it's valid for (a cert can't be used to impersonate nas.yourlab.example if it was signed only for router), and -V +52w gives it a year — re-sign on rotation. The @cert-authority line means clients now validate your servers against your CA instead of prompting on every new box. TOFU fingerprint prompts die.

Step 3: Sign user keys with principals and short validity//

For each person (or deploy identity), sign their existing key — no new keypair needed if they already have one:

ssh-keygen -s user_ca -I alice -n alice,deploy -V +8h ~/.ssh/id_ed25519.pub

That drops id_ed25519-cert.pub next to the public key; ssh picks it up automatically. No key ever lands in any server's authorized_keys.

Why -n alice,deploy: that's the cert's principal list — which local accounts this credential may map to. And note what happens if you omit -n entirely: per sshd_config(5), certificates that lack a principals list are not permitted via TrustedUserCAKeys at all, so you end up with a signed key that matches nobody — a dead credential that's easy to miss in an audit. Never sign without it (that's failure mode #2 below).

Step 4: Point every server at the CA, and gate each account//

On each server's sshd_config:

TrustedUserCAKeys /etc/ssh/user_ca.pub AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u

%u is a token that expands to the username being authenticated — so /etc/ssh/auth_principals/alice lists which principals may log in as alice. For an actual account, put one line per allowed principal:

# /etc/ssh/auth_principals/alice alice deploy

Why: without AuthorizedPrincipalsFile, the cert's principal list is the only gate — any CA-signed cert whose principals happen to match a local username maps straight through. The per-host file lets host A allow deploy while host B (the NAS) doesn't, even though both trust the same CA.

Step 5: Cut the old ways, add revocation//

PermitRootLogin no PasswordAuthentication no RevokedKeys /etc/ssh/revoked_keys.krl

Build KRLs when you need to revoke before expiry — per ssh-keygen(1), -k builds a Key Revocation List:

# revoke a specific cert or key: ssh-keygen -k -f revoked.krl path/to/offender.pub # to revoke a certificate by serial number or key ID instead, feed a KRL spec (e.g. "serial:12345") and pass the signing CA with -s # then distribute revoked.krl to every server (it's just a file)

The PermitRootLogin no line is the same move as step 1 of the router hygiene drill — here it means root goes through the same signed-cert path as everyone else. Why KRLs: expiry handles planned offboarding; a KRL handles "the laptop got stolen at 9am" without waiting for the cert's -V window to close or SSHing into every box to edit authorized_keys.

Verify it worked (non-negotiable)//

  1. Watch the cert get accepted: ssh -v alice@host — the verbose output shows the certificate being offered and accepted, not a password prompt.
  2. Delete the account's authorized_keys entry for that user; confirm cert-only login still works (this is the moment you know the break-glass key can go).
  3. Prove the principal gate: sign a test cert only for principal deploy, then attempt to log in as alice with it — it should fail, and vice versa. (Quick variant: ssh -v shows which principals were offered.)
  4. Inspect any cert you doubt — -L from ssh-keygen(1) prints the full certificate including principals and validity window:
ssh-keygen -L -f id_ed25519-cert.pub # ... Valid: from 2026-09-08T01:36 to 2026-09-08T09:37, Principals: alice deploy

That last one is your audit tool — a stray cert found on disk tells you exactly who it was for and when it stopped being valid.

When it breaks//

  1. Clock skew = silent failure. Certs have validity windows; if the server's clock is behind the cert's valid after timestamp or ahead of its expiry, auth fails with no dramatic error — ssh just ends with Permission denied (publickey). NTP on every host is mandatory, not recommended.
  2. Empty principals list = a dead credential. You signed without -n. Per sshd_config(5), certificates that lack a list of principals "will not be permitted for authentication using TrustedUserCAKeys", so the user just gets Permission denied (publickey) with no obvious reason. Find it with ssh-keygen -L, re-sign with an explicit principal list, and revoke the old cert via KRL if it was ever distributed.
  3. Forgot AuthorizedPrincipalsFile — now any CA-signed principal that matches a local account name maps through on every server. Add the directive (Step 4), restart sshd, re-verify with test #3 above.
  4. CA private key compromise = attacker can mint certs for anyone. Rotation means updating TrustedUserCAKeys on all servers and the @cert-authority entries on all clients — OpenSSH has no intermediate CAs, so trust both old and new CA during cutover, then drop the old one.
  5. Restarting sshd kills your session. Do it over a console (or IPMI/iDRAC), or with an already-open second session you're not using for the restart.

Filed under

Sources

3 cited · 0 primary
  1. man.openbsd.org/sshd_config.5Reporting
  2. man.openbsd.org/ssh-keygen.1Reporting
  3. openssh.org/releasenotes.htmlReporting