Short answer. With authorized_keys, every server lists every public key allowed to log in, and access
lasts until someone deletes the line. With OpenSSH certificates, servers trust one user CA
(TrustedUserCAKeys). Users get their own key signed with ssh-keygen -s, which adds an identity (-I), the
accounts it may log into (principals, -n) and a validity window (-V). Clients trust one host CA
(@cert-authority in known_hosts) instead of accepting each host's fingerprint on first use. Short-lived
certificates make offboarding automatic, and Key Revocation Lists (RevokedKeys) handle the rest. The price
is that you now run a CA and must protect its key.
What authorized_keys does not do
~/.ssh/authorized_keys is simple and has no moving parts, which is why it is everywhere. Its limits show up
with more people and more servers:
- Access never expires. A key added for a contractor in 2023 still works in 2026 unless someone removes it,
on every server it was copied to. (
expiry-time=exists per line, but someone has to set it.) - No identity. The line is a key and a comment. Which person, team or job it belongs to lives in a spreadsheet, if anywhere.
- Distribution is your problem. Adding or removing someone means touching every server, with config management or by hand.
- Host keys are trust on first use. Each client accepts each server's fingerprint the first time, and
rebuilding a server triggers
REMOTE HOST IDENTIFICATION HAS CHANGEDeverywhere.
OpenSSH certificates, available since OpenSSH 5.4 (2010), address all four. They are not X.509: the format is
OpenSSH's own, a public key plus identity and constraints, signed by a CA key that is itself an ordinary SSH key.
In 2025 the format description moved from the PROTOCOL.certkeys file in the OpenSSH sources to an Internet-Draft,
now draft-ietf-sshm-cert in the IETF SSHM working group.
Two CAs, two directions
You need two separate signing keys, because they answer different questions:
- The user CA answers "may this key log in as this account?" Servers trust it.
- The host CA answers "is this really
web01?" Clients trust it.
$ ssh-keygen -q -t ed25519 -f user_ca -C "Example user CA" -N ""
$ ssh-keygen -q -t ed25519 -f host_ca -C "Example host CA" -N ""
The -N "" keeps the lab simple. A real CA key belongs in an HSM, a hardware token, or a signing service
(HashiCorp Vault's SSH secrets engine, smallstep, Teleport and cloud-provider equivalents all do this). Whoever
holds the user CA key can log in as anyone the servers allow.
User certificates
Sign Alice's existing public key for eight hours, for the accounts alice and deploy:
$ ssh-keygen -s user_ca -I alice@example.test -n alice,deploy -V -5m:+8h -z 1001 id_ed25519.pub
Signed user key /home/alice-laptop/id_ed25519-cert.pub: id "alice@example.test" serial 1001 for alice,deploy valid from 2026-10-20T13:14:08 to 2026-10-20T21:19:08
-Iis the key ID. sshd logs it on every login, so make it a person or job, not a hostname.-nlists the principals, the names this certificate may act as. The ssh-keygen manual says certificates without principals are "valid for all users or hosts", but sshd 10.0 refuses such a user certificate underTrustedUserCAKeys(Certificate lacks principal list). Always set-n.-V -5m:+8hsets validity from five minutes ago (clock skew) to eight hours from now.-zsets a serial number, which revocation can refer to.
ssh-keygen -L shows what was signed:
/home/alice-laptop/id_ed25519-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:9Mr4hhu4+c6LnVoj+n8tVj4AKD/AP037BQ5pyEAv07s
Signing CA: ED25519 SHA256:aG9VR0NrG4he7YusGsggg/Gt3WviRbg3Anbfz4HJe60 (using ssh-ed25519)
Key ID: "alice@example.test"
Serial: 1001
Valid: from 2026-10-20T13:14:08 to 2026-10-20T21:19:08
Principals:
alice
deploy
Critical Options: (none)
Extensions:
permit-X11-forwarding
permit-agent-forwarding
permit-port-forwarding
permit-pty
permit-user-rc
By default a user certificate grants all five extensions. -O clear removes them, and you can then add back only
what you need, for example -O clear -O permit-pty. Critical options restrict the certificate.
force-command replaces whatever command the client asks for, and source-address limits where it may be used
from. With source-address=10.0.0.0/8, a login from 127.0.0.1 is refused. With an allowed source, the forced
command runs no matter what the client asked for:
Critical Options:
force-command echo backup only
source-address 10.0.0.0/8
$ ssh deploy@web01.example.test
deploy@web01.example.test: Permission denied (publickey).
cert: Authentication tried for deploy with valid certificate but not from a permitted source address (127.0.0.1).
Refused by certificate options
...
$ ssh deploy@web01.example.test "rm -rf /tmp/anything"
backup only
Starting session: forced-command (key-option) 'echo backup only' for deploy from 127.0.0.1 port 48976 id 0
The ssh client finds the certificate automatically when it sits next to the private key as
id_ed25519-cert.pub. Otherwise point to it with CertificateFile. ssh-add loads both into the agent.
Configuring sshd
The whole server side is a few lines. This drop-in file passed sshd -t and was used for every test on this page:
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
RevokedKeys /etc/ssh/revoked_keys
PasswordAuthentication no
LogLevel VERBOSE
TrustedUserCAKeysholds one or more CA public keys. During a CA rotation, list both.AuthorizedPrincipalsFilemaps accounts to principals. Here/etc/ssh/auth_principals/deploycontains the single linedeploy, so only certificates that list the principaldeploymay log in asdeploy. Without this option, sshd requires the account name itself among the principals. Mapping is more flexible: a principal such asoncallcan be allowed on many accounts, andAuthorizedPrincipalsCommandcan look it up in a directory.RevokedKeyspoints to a revocation list (see below).LogLevel VERBOSElogs the certificate key ID and serial of each login. This is the audit trailauthorized_keysnever had.
A login with Alice's certificate, from both sides:
Server host certificate: ssh-ed25519-cert-v01@openssh.com SHA256:IuzvI4QsLmSx1+6p3yrvlRx2wdl+LUCY1zXqSzssLAc, serial 0 ID "web01" CA ssh-ed25519 SHA256:+iie/YHPulJReHN2GQ8UvrorBVrtayD2T1MGxeg/QCQ valid from 2026-10-20T13:14:08 to 2027-10-19T13:19:08
Host 'web01.example.test' is known and matches the ED25519-CERT host certificate.
Offering public key: /home/alice-laptop/id_ed25519 ED25519-CERT SHA256:9Mr4hhu4+c6LnVoj+n8tVj4AKD/AP037BQ5pyEAv07s explicit
Server accepts key: /home/alice-laptop/id_ed25519 ED25519-CERT SHA256:9Mr4hhu4+c6LnVoj+n8tVj4AKD/AP037BQ5pyEAv07s explicit
Authenticated to web01.example.test ([127.0.0.1]:22) using "publickey".
Accepted publickey for deploy from 127.0.0.1 port 57432 ssh2: ED25519-CERT SHA256:9Mr4hhu4+c6LnVoj+n8tVj4AKD/AP037BQ5pyEAv07s ID alice@example.test (serial 1001) CA ED25519 SHA256:aG9VR0NrG4he7YusGsggg/Gt3WviRbg3Anbfz4HJe60
If you can't edit sshd_config, a single account can trust a CA through its own authorized_keys. The
principals= option works like the principals file:
cert-authority,principals="deploy-team" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK63...
$ ssh deploy@web01.example.test 'echo logged in as $(whoami)'
logged in as deploy
Host certificates
Sign each server's host key with the host CA. -h makes it a host certificate, and the principals are the names
clients use to connect:
$ ssh-keygen -s host_ca -I web01 -h -n web01.example.test,web01 -V -5m:+52w /etc/ssh/ssh_host_ed25519_key.pub
Signed host key /etc/ssh/ssh_host_ed25519_key-cert.pub: id "web01" serial 0 for web01.example.test,web01 valid from 2026-10-20T13:14:08 to 2027-10-19T13:19:08
Then point HostCertificate at the result (as in the sshd file above), and give clients one line in
known_hosts (or /etc/ssh/ssh_known_hosts) instead of a fingerprint per host:
@cert-authority *.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBtSsqoqxwcl31DTJg3kTPsv9eLuuw5gU1BcvYybODlu Example host CA
New and rebuilt servers are then trusted on the first connection, without a prompt, as long as their certificate lists the name the client used. That last condition is the usual surprise. Connecting under a name that is not a principal makes the client fall back to plain host keys, and with strict checking it stops:
$ ssh deploy@db01.example.test
Certificate invalid: name is not a listed principal
No ED25519 host key is known for db01.example.test and you have requested strict checking.
Host key verification failed.
List every name clients use, including short names, CNAMEs, IP addresses if people connect by IP, and
load-balancer names. ssh-keyscan -c fetches a server's certificate for inspection, and @revoked lines in
known_hosts reject a specific host key or CA.
What failures look like
The client only ever says Permission denied (publickey). for user certificate problems. The reason is in the
server log, at LogLevel VERBOSE:
| Situation | sshd log (OpenSSH 10.0p2) |
|---|---|
| Account not in the certificate's principals (after mapping) | Certificate does not contain an authorized principal |
Certificate issued without -n, no AuthorizedPrincipalsFile | Certificate lacks principal list |
Certificate past its -V window | Certificate invalid: expired |
| Serial or key in the KRL | Authentication key ED25519-CERT SHA256:... revoked by file /etc/ssh/revoked_keys |
source-address does not match | cert: Authentication tried for deploy with valid certificate but not from a permitted source address (127.0.0.1). |
Two things that look like certificate problems but are not:
- Clock skew. Validity is checked against the server's clock. Issue certificates valid from a few minutes in
the past (
-V -5m:...) and keep servers on NTP. - Penalties from repeated failures. Since OpenSSH 9.8, sshd penalises addresses that keep failing
authentication (
PerSourcePenalties, on by default). While testing certificates from one machine, a failed attempt led to the next connection being dropped before authentication:
srclimit_penalise: 127.0.0.1/32: activating ipv4 penalty of 19 seconds for penalty: failed authentication
$ ssh deploy@web01.example.test
Connection closed by 127.0.0.1 port 22
drop connection #0 from [127.0.0.1]:55080 on [127.0.0.1]:22 penalty: failed authentication
If ssh reports Connection closed right after a run of failures, wait out the penalty before you change your
configuration again.
Revocation with KRLs
Short validity is the main revocation mechanism: an eight-hour certificate cleans itself up. For the rest, OpenSSH has Key Revocation Lists (KRLs, since OpenSSH 6.2), a compact binary format that can revoke by serial, key ID or key. Revoking Alice's certificate with serial 1001:
$ printf 'serial: 1001\n' > /tmp/krl-spec
$ ssh-keygen -k -f /etc/ssh/revoked_keys -s user_ca.pub /tmp/krl-spec
Revoking from /tmp/krl-spec
$ ssh-keygen -Q -f /etc/ssh/revoked_keys id_ed25519-cert.pub
/home/alice-laptop/id_ed25519-cert.pub (alice@laptop): REVOKED
sshd reads RevokedKeys on each authentication, so the next login fails without a restart. Add -u to update
an existing KRL instead of replacing it. Distribute the KRL with the same tooling as the CA key. A server with a
stale KRL accepts what it does not know is revoked. That is one more reason to keep certificates short-lived and
treat the KRL as a backstop for the remaining hours.
Side by side
| authorized_keys | Certificates | |
|---|---|---|
| Granting access | Copy a key to each server | Sign a key once |
| Removing access | Delete it on each server | Let it expire, or add it to the KRL |
| Expiry | Optional per line (expiry-time=) | Every certificate has a validity window |
| Identity in logs | Key fingerprint | Key ID and serial |
| Restrictions | from=, command=, restrict per line | source-address, force-command, extensions per certificate |
| Host trust | Fingerprint per host (TOFU) | One @cert-authority line |
| Critical secret | None central | The CA private key |
| Moving parts | None | Issuance, CA protection, KRL distribution |
The per-line restrictions in authorized_keys are real and tested on the same server:
restrict,from="10.0.0.0/8",command="echo backup only" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
$ ssh deploy@web01.example.test id
deploy@web01.example.test: Permission denied (publickey).
/home/deploy/.ssh/authorized_keys:1: Authentication tried for deploy with correct key but not from a permitted host (host=127.0.0.1, ip=127.0.0.1, required=10.0.0.0/8).
expiry-time="20261001" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
$ ssh deploy@web01.example.test id
deploy@web01.example.test: Permission denied (publickey).
For a handful of servers and people, authorized_keys with restrict and expiry-time is fine. Certificates
pay off once onboarding, offboarding and host rebuilds happen weekly, or when you need to know who logged in,
not just which key.
Migrating from authorized_keys
You don't need a flag day. sshd checks authorized_keys and trusted CAs side by side, so both kinds of login
work during the move:
- Deploy the CA trust first. Roll out
TrustedUserCAKeys, the principals files and an empty KRL to all servers, and leaveauthorized_keysalone. - Issue certificates to a first team and let them use them for a few weeks. Each login now shows up in the
log with
ID ... (serial ...). - Find the stragglers. A login through
authorized_keysis logged without a certificate ID, for exampleAccepted publickey for deploy from 127.0.0.1 port 58770 ssh2: ED25519 SHA256:tT5X32Wds5InYaLSh7mV+QRqAtxA4KXwlKV3hkf0YkI. Those are usually automation: CI runners, backup jobs, monitoring. Give each one its own short-lived or narrowly restricted certificate. - Switch off key files with
AuthorizedKeysFile noneonce nothing legitimate uses them. Keep a break-glass path, such as console access or a separate emergency CA kept offline, for the day the signing service is down. - Do the host side separately. Sign host keys and add the
@cert-authorityline to the clients' global/etc/ssh/ssh_known_hosts. The old per-hostknown_hostsentries can stay. They keep working until the host keys change.
Running it in practice
- Issue certificates from your identity provider. A signing service checks the user's SSO login (OIDC) and returns a certificate with their principals for a few hours. Nobody handles the CA key, and leaving the company ends SSH access by the next day without touching a server.
- Keep the user key on hardware if you can.
ssh-keygen -t ed25519-skcreates a FIDO2-backed key. Certificates work with it like with any other key. - Rotate the CA with overlap. Add the new user CA as a second line in
TrustedUserCAKeys, or the new host CA as a second@cert-authorityline. Issue from the new CA, then remove the old one. - Watch the host side too. Re-sign host keys before their certificates expire. An expired host certificate makes clients fall back to plain host keys, which they usually don't have:
Certificate invalid: expired
No ED25519 host key is known for web01.example.test and you have requested strict checking.
Host key verification failed.
To inspect a key or certificate file without running anything, the SSH key inspector decodes OpenSSH keys and shows their type, size and fingerprint. The SSH server audit checks what a server offers, including its host-key and certificate algorithms and post-quantum key exchange.
Sources
- OpenSSH manual pages: ssh-keygen(1) (CERTIFICATES and KEY REVOCATION
LISTS sections), sshd_config(5) (
TrustedUserCAKeys,AuthorizedPrincipalsFile,RevokedKeys,HostCertificate,PerSourcePenalties), sshd(8) (authorized_keys and known_hosts formats). - draft-ietf-sshm-cert: SSH Certificate Format (the
successor of OpenSSH's
PROTOCOL.certkeys). PROTOCOL.krl: the KRL format. - OpenSSH release notes: 5.4 (certificates), 6.2 (KRLs), 9.8
(
PerSourcePenalties).
All output on this page comes from OpenSSH 10.0p2 (Debian 13 packages) in debian:trixie-20261005-slim on
20 October 2026, with test keys generated in the container.