How SSH Certificates Differ from Authorized Keys
With authorized_keys, every server stores a copy of every public key that should access it. Add a new engineer, and you touch every host. Remove a contractor, and you hope your automation caught every host. There is no expiry by default, no centralized revocation, and auditing is a grep exercise across your fleet.
SSH certificates work like TLS certificates. You generate a CA key pair. The CA signs user or host public keys, producing a certificate that carries metadata: who issued it, what principals (usernames or hostnames) it is valid for, when it expires, and what extensions are permitted. Servers trust the CA, not individual keys. A user presents their certificate, the server checks the CA signature and the certificate fields, and access is granted or denied without consulting any local file.
The critical operational difference: when you want to revoke access, you update a single revocation list on the CA or distribute a new KRL (Key Revocation List) to hosts. You do not touch 500 authorized_keys files. And because certificates carry a NotAfter timestamp, a 12-hour certificate issued to a contractor expires automatically. No cleanup job required.
Host certificates solve the other side of the same problem. By default, users see a 'host key changed' warning and have to manually accept new host fingerprints. With host certificates, your servers present a signed certificate and users trust the CA. No more known_hosts sprawl, no more 'REMOTE HOST IDENTIFICATION HAS CHANGED' panic on legitimate infrastructure changes.
CA Key Generation and Offline Storage Strategy
You need two CA keys: one for signing host certificates and one for signing user certificates. Keeping them separate means a compromise of the user CA does not let an attacker forge host certificates, and vice versa. Both CA private keys should live offline or in an HSM in production. For most shops without HSM budget, an air-gapped signing machine or at minimum a dedicated bastion with full-disk encryption and no network egress for the CA keys is the baseline.
Generate 4096-bit RSA or Ed25519 CA keys. Ed25519 is faster and produces smaller signatures. Use Ed25519 unless you have a compliance requirement forcing RSA.
Store the CA private keys with a strong passphrase. In production, use a secrets manager (HashiCorp Vault's SSH secrets engine automates all of this, covered below) or at minimum store the encrypted key in a location only accessible to your signing service account.
# Generate the host CA key pair
ssh-keygen -t ed25519 -f /etc/ssh/ca/ssh_host_ca -C "host-ca-prod-2026" -N "$(openssl rand -base64 32)"
# Generate the user CA key pair
ssh-keygen -t ed25519 -f /etc/ssh/ca/ssh_user_ca -C "user-ca-prod-2026" -N "$(openssl rand -base64 32)"
# Verify
ls -la /etc/ssh/ca/
# -rw------- ssh_host_ca
# -rw-r--r-- ssh_host_ca.pub
# -rw------- ssh_user_ca
# -rw-r--r-- ssh_user_ca.pub
# Set strict permissions
chmod 700 /etc/ssh/ca
chmod 600 /etc/ssh/ca/ssh_host_ca /etc/ssh/ca/ssh_user_ca
chmod 644 /etc/ssh/ca/ssh_host_ca.pub /etc/ssh/ca/ssh_user_ca.pub
Issuing Host Certificates
Each server's existing host key (in /etc/ssh/) gets signed by the host CA. You do not replace the host key - you just add a certificate alongside it. The certificate file is named with a -cert.pub suffix by convention.
The -n flag sets the principals, which are the hostnames or IP addresses the certificate is valid for. Set these to match exactly what clients will use: the short hostname, the FQDN, and any aliases. The -V flag sets validity. For host certificates, longer validity (1-5 years) is typically acceptable because host key rotation is disruptive and host compromise is detectable through other means.
After generating, distribute the certificate back to the host and update sshd_config to announce it.
# On the CA machine, sign the host's public key
# First, securely copy the host's public key to the CA machine
scp root@web01.prod.example.com:/etc/ssh/ssh_host_ed25519_key.pub /tmp/web01_host_key.pub
# Sign it: valid for 1 year, principals include short name, FQDN, and IP
ssh-keygen -s /etc/ssh/ca/ssh_host_ca \
-I "web01.prod.example.com" \
-h \
-n "web01,web01.prod.example.com,10.0.1.50" \
-V +52w \
/tmp/web01_host_key.pub
# This produces: /tmp/web01_host_key-cert.pub
# Inspect the certificate
ssh-keygen -L -f /tmp/web01_host_key-cert.pub
# Copy the certificate back to the host
scp /tmp/web01_host_key-cert.pub root@web01.prod.example.com:/etc/ssh/ssh_host_ed25519_key-cert.pub
# On web01: add to sshd_config
echo 'HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub' >> /etc/ssh/sshd_config
systemctl reload sshd
Configuring Clients to Trust the Host CA
Once hosts present certificates, clients need to trust the CA instead of individual fingerprints. Remove the old known_hosts entries and replace them with a @cert-authority line. This can be done system-wide in /etc/ssh/known_hosts or per-user in ~/.ssh/known_hosts.
The @cert-authority line tells SSH: any host presenting a certificate signed by this CA key, matching this hostname pattern, is trusted. The * wildcard covers all hosts. In production you may want to scope this to your domain pattern.
Deploy this via your configuration management tool (Ansible, Puppet, Chef) so every workstation and bastion host gets the CA public key without manual distribution. When you rotate the CA, update this file and redeploy - no individual host fingerprint hunting.
# System-wide on client machines: /etc/ssh/known_hosts
# Or per-user: ~/.ssh/known_hosts
# Format: @cert-authority
@cert-authority *.prod.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleHostCAPublicKeyHere host-ca-prod-2026
# Verify a host certificate is being presented
ssh -v web01.prod.example.com 2>&1 | grep -i cert
# Should show: Server host certificate: /etc/ssh/ssh_host_ed25519_key-cert.pub
# Test without known_hosts interference
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes web01.prod.example.com
Issuing User Certificates with Principals
User certificates are where the real operational leverage lives. Instead of pushing public keys to every server, you sign a user's public key with your user CA and they can authenticate anywhere that trusts the CA - scoped by principals.
Principals in user certificates map to Unix usernames. If you issue a certificate with principal 'deploy', the user can authenticate as the deploy user on any server that trusts the user CA and has 'deploy' in its AuthorizedPrincipalsFile. This is the access control lever. A senior engineer gets principals 'ec2-user,deploy,admin'. A new contractor gets principal 'deploy' only, on specific server groups.
The -O flag adds extensions. permit-pty allows terminal allocation. permit-port-forwarding and permit-agent-forwarding can be omitted to restrict users to non-interactive use cases. no-x11-forwarding is sensible for most server access.
Short validity periods are the main security advantage over static keys. For human users, 8-24 hours forces re-authentication through your IdP daily. For CI/CD pipelines, 15-60 minute certificates are practical. If a key is compromised, the window is bounded.
For DevOps automation, tools like taskbotshub.ai can integrate certificate issuance directly into pipeline steps, requesting a fresh certificate at job start rather than storing long-lived SSH keys in CI secrets.
# Sign a user's public key - valid 8 hours, principals deploy and ec2-user
ssh-keygen -s /etc/ssh/ca/ssh_user_ca \
-I "jsmith@example.com-$(date +%Y%m%d)" \
-n "deploy,ec2-user" \
-V +8h \
-O permit-pty \
-O permit-port-forwarding \
-O no-x11-forwarding \
/home/jsmith/.ssh/id_ed25519.pub
# This creates: /home/jsmith/.ssh/id_ed25519-cert.pub
# Inspect the issued certificate
ssh-keygen -L -f /home/jsmith/.ssh/id_ed25519-cert.pub
# Output includes:
# Type: ssh-ed25519-cert-v01@openssh.com user certificate
# Public key: ED25519-CERT SHA256:...
# Signing CA: ED25519 SHA256:...
# Key ID: "jsmith@example.com-20260827"
# Serial: 0
# Valid: from 2026-08-27T09:00:00 to 2026-08-27T17:00:00
# Principals: deploy, ec2-user
# Extensions: permit-pty, permit-port-forwarding
Server-Side Configuration for User CA Trust
Servers need to know about the user CA and what principals are permitted per user. Two directives in sshd_config handle this: TrustedUserCAKeys points at the CA public key file, and AuthorizedPrincipalsFile defines where to look up which principals each local user accepts.
The AuthorizedPrincipalsFile approach gives you fine-grained control. Each local Unix user has a file listing the certificate principals allowed to authenticate as that user. This means you can allow 'deploy' principal certs to authenticate as the local 'deploy' user, but not as root, even if the certificate lists both.
Alternatively, AuthorizedPrincipalsCommand lets you query a database or LDAP in real time rather than maintaining flat files. For large fleets, this is the right approach - it centralizes the principal-to-local-user mapping.
Disable AuthorizedKeysFile once you have fully migrated to certificates, or attackers can still plant keys. Set it to /dev/null to be explicit.
# /etc/ssh/sshd_config additions on all managed servers
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
# Disable legacy authorized_keys after migration
AuthorizedKeysFile /dev/null
# Create principals files for local users
mkdir -p /etc/ssh/auth_principals
# The deploy user accepts certs with principal 'deploy'
echo 'deploy' > /etc/ssh/auth_principals/deploy
chmod 644 /etc/ssh/auth_principals/deploy
# The ec2-user accepts 'ec2-user' and 'admin' principals
printf 'ec2-user\nadmin\n' > /etc/ssh/auth_principals/ec2-user
# Distribute the user CA public key to all hosts
# (run from your Ansible control node)
ansible all -m copy \
-a "src=/etc/ssh/ca/ssh_user_ca.pub dest=/etc/ssh/user_ca.pub mode=0644"
ansible all -m service -a "name=sshd state=reloaded"
Vault SSH Secrets Engine for Automated Issuance
Running a manual signing workflow does not scale. HashiCorp Vault's SSH secrets engine automates certificate issuance with policy-based controls, audit logging, and short TTLs enforced server-side. This is the production pattern for any fleet above 20 hosts.
Vault acts as your CA. You mount the SSH secrets engine, configure it with your CA key (or let Vault generate one), define roles that specify allowed principals, max TTL, and allowed extensions, and then grant Vault policies to teams or CI systems to request certificates from specific roles.
The operational workflow: engineer logs in via LDAP/OIDC, their Vault token grants them access to the 'ssh-prod-engineer' role, they run vault ssh or a wrapper script, Vault issues a 4-hour certificate with their email as the key ID, and SSH access works. No manual CA involvement after initial setup.
Certificate serial numbers and key IDs are logged in Vault's audit log, giving you a complete record of who got which certificate when - something authorized_keys never provided.
# Enable the SSH secrets engine
vault secrets enable -path=ssh ssh
# Configure Vault as the CA (generate a new key)
vault write ssh/config/ca generate_signing_key=true key_type=ed25519
# Retrieve the public CA key to distribute to hosts
vault read -field=public_key ssh/config/ca > /tmp/vault_user_ca.pub
# Create a role for engineers
vault write ssh/roles/prod-engineer \
key_type=ca \
allowed_users="deploy,ec2-user" \
default_user=ec2-user \
ttl=4h \
max_ttl=8h \
allowed_extensions="permit-pty,permit-port-forwarding" \
default_extensions='{"permit-pty":""}'
# Create a role for CI/CD with shorter TTL
vault write ssh/roles/ci-deploy \
key_type=ca \
allowed_users="deploy" \
default_user=deploy \
ttl=15m \
max_ttl=30m \
allowed_extensions="permit-port-forwarding"
# Sign a key (engineer workflow)
vault write ssh/sign/prod-engineer \
public_key=@~/.ssh/id_ed25519.pub \
valid_principals=deploy
# Or use vault ssh helper directly
vault ssh -role=prod-engineer -mode=ca deploy@web01.prod.example.com
Key Revocation Lists and Forced Expiry
Certificates expire automatically, but you sometimes need immediate revocation before expiry - a compromised key, a terminated employee whose certificate has 6 hours left, or a service account that needs to be killed now.
OpenSSH supports Key Revocation Lists (KRL) via the RevokedKeys directive in sshd_config. KRLs are binary files generated with ssh-keygen -k. You can revoke by key, certificate serial number, or key ID. Revoking by serial is cleanest when using Vault because Vault tracks serials.
Vault handles revocation differently: when you revoke a Vault token or lease, future certificate requests from that token fail. But already-issued certificates remain valid until they expire - KRLs are still needed for immediate revocation of outstanding certs.
For Vault-issued certs, the key_id field contains the entity ID and timestamp. Use this in KRL generation to revoke all certs issued to a specific user. Distribute the updated KRL to all hosts via configuration management whenever it changes. The distribution latency (seconds to minutes with Ansible) is your revocation window.
# Generate a KRL file revoking specific certificates
# Revoke by key ID (matches -I flag when signing)
ssh-keygen -k -f /etc/ssh/revoked_keys \
-u \
-z 1 \
/tmp/compromised_user_key.pub
# Revoke by serial number range
ssh-keygen -k -f /etc/ssh/revoked_keys \
-u \
serial:1000-1005
# Inspect the KRL
ssh-keygen -Q -f /etc/ssh/revoked_keys /tmp/test_key.pub
# Output: /tmp/test_key.pub: REVOKED
# Add to sshd_config on all hosts
echo 'RevokedKeys /etc/ssh/revoked_keys' >> /etc/ssh/sshd_config
# Distribute updated KRL via Ansible
ansible all -m copy \
-a "src=/etc/ssh/revoked_keys dest=/etc/ssh/revoked_keys mode=0644"
ansible all -m service -a "name=sshd state=reloaded"
# Verify revocation is working
ssh -i /tmp/revoked_key.pub user@host
# Permission denied (publickey) - revocation works
Ansible Playbook for Fleet Enrollment
Enrolling 500 hosts manually defeats the purpose. The playbook pattern: gather each host's existing public key, sign it on the CA, push the certificate back, update sshd_config, and reload. This runs once per host during initial enrollment and then periodically to renew host certificates before expiry.
The signing step has to run on the CA machine or against Vault's API. For Vault-backed CAs, use the uri module to hit the Vault API directly in the playbook, passing the host's public key and getting back a signed certificate - no CA machine SSH hop required.
For teams building out infrastructure automation, taskbotshub.ai provides pre-built DevOps automation workflows that can handle the certificate lifecycle as part of a broader infrastructure-as-code pipeline, including integration with Vault and Ansible.
Name your CA roles, key IDs, and principal schemas clearly from the start. Certificates embed the key ID as an auditable identifier, so 'jsmith@example.com-20260827' is useful in audit logs while 'key1' is not. If you are establishing naming conventions for your infrastructure components, projects, or internal tooling, a clean naming structure pays dividends when you are grepping Vault audit logs six months later. Some teams use nicename.me when thinking through internal project naming schemes and domain strategy for internal tooling infrastructure.
---
# ssh_ca_enroll.yml - enroll hosts into SSH CA
- name: Enroll hosts into SSH CA
hosts: all
become: true
vars:
vault_addr: "https://vault.prod.example.com:8200"
vault_token: "{{ lookup('env', 'VAULT_TOKEN') }}"
ssh_key_type: ed25519
tasks:
- name: Read existing host public key
slurp:
src: "/etc/ssh/ssh_host_{{ ssh_key_type }}_key.pub"
register: host_pubkey
- name: Sign host key via Vault API
uri:
url: "{{ vault_addr }}/v1/ssh/sign/host-cert"
method: POST
headers:
X-Vault-Token: "{{ vault_token }}"
body_format: json
body:
public_key: "{{ host_pubkey.content | b64decode }}"
cert_type: host
valid_principals: "{{ inventory_hostname }},{{ ansible_fqdn }},{{ ansible_default_ipv4.address }}"
register: vault_response
delegate_to: localhost
- name: Write host certificate
copy:
content: "{{ vault_response.json.data.signed_key }}"
dest: "/etc/ssh/ssh_host_{{ ssh_key_type }}_key-cert.pub"
mode: '0644'
- name: Configure sshd for host certificate
lineinfile:
path: /etc/ssh/sshd_config
line: "HostCertificate /etc/ssh/ssh_host_{{ ssh_key_type }}_key-cert.pub"
regexp: '^HostCertificate'
notify: reload sshd
handlers:
- name: reload sshd
service:
name: sshd
state: reloaded
Auditing and Monitoring Certificate Usage
SSH certificate authentication logs to syslog like key-based auth, but the log line includes the certificate serial number and key ID when LogLevel is set to VERBOSE or higher in sshd_config. This is the audit trail that authorized_keys never gave you.
Set LogLevel VERBOSE in sshd_config and parse the key ID from auth logs. With Vault-issued certs where key IDs include the user's email and timestamp, you can correlate every SSH session to a specific Vault token issuance event, the user's LDAP identity, and the cert's validity window.
Ship sshd logs to your SIEM. Build alerts for: authentication with certificates expiring within 24 hours (suggests automation is not rotating certs), authentication failures with revoked keys (active attack or misconfiguration), and any successful authentication outside normal working hours for principals that should not be active then.
For host certificate expiry monitoring, parse the certificate files directly. The ssh-keygen -L output includes the validity period in machine-readable form. A daily cron job or monitoring script that alerts when any host certificate expires within 30 days prevents the 'all SSH access broke Friday at midnight' incident.
# /etc/ssh/sshd_config - enable verbose logging for audit
LogLevel VERBOSE
# Sample log line with VERBOSE (OpenSSH 9.x)
# Aug 27 14:23:01 web01 sshd[12345]: Accepted certificate ID "jsmith@example.com-20260827"
# (serial 47) signed by user CA SHA256:AbCdEf... for deploy from 10.0.0.5 port 54321
# Parse cert expiry from all host cert files
#!/bin/bash
for cert in /etc/ssh/*-cert.pub; do
expiry=$(ssh-keygen -L -f "$cert" 2>/dev/null | grep 'Valid:' | awk '{print $NF}')
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
if [ "$days_left" -lt 30 ]; then
echo "WARNING: $cert expires in ${days_left} days ($expiry)"
fi
done
# Count cert auth events per user per hour from auth.log
grep 'Accepted certificate' /var/log/auth.log | \
awk '{print $9}' | \
sort | uniq -c | sort -rn | head -20