Introduction
Static SSH keys are a ticking time bomb. Once a private key is copied to a developer's laptop, a CI runner, or a backup archive, you lose all visibility into who is using it and when it expires — because it never does. When that laptop is lost or that contractor leaves, you're hunting through ~/.ssh/authorized_keys files across a dozen servers hoping you caught every copy.
SSH certificate authentication solves this at the root. You run a lightweight Certificate Authority (CA), sign short-lived user certificates (e.g. 8-hour TTL), and every server trusts the CA — not individual public keys. Revocation is instant: stop signing, and stale keys can't authenticate. Combine this with a locked-down sshd_config and you have an SSH posture that holds up under a real audit.
This guide covers:
- Standing up an SSH CA (user CA + host CA)
- Signing host certificates so clients can verify servers (eliminating TOFU risk)
- Signing user certificates with a short TTL
- Hardening
sshd_configto close common attack vectors - Verifying and troubleshooting the setup
No external tooling required — everything here uses stock OpenSSH and standard Linux commands.
Prerequisites
Before you begin, confirm your OpenSSH version supports the features used here:
ssh -V
# OpenSSH_8.9p1 Ubuntu-3ubuntu0.10, OpenSSL 3.0.2 ...OpenSSH 8.0 or later is required for the -I identity comment flag and principal enforcement used in this guide. Most modern distros ship a compatible version.
You'll also want at least two machines handy:
- CA host — a dedicated, hardened machine that will hold the CA private keys. It should be offline or firewalled when not actively signing. In small environments, a separate VM or even a YubiKey-backed setup works.
- SSH server — the Linux host you want to harden.
- Client machine — where your admin will initiate SSH sessions.
Step 1 — Create the Certificate Authority Keys
On your CA host, generate two separate CA keypairs: one for signing user certificates, one for signing host certificates. Keeping them separate limits blast radius if one is compromised.
# Create a dedicated directory for CA keys
sudo mkdir -p /etc/ssh/ca
sudo chmod 700 /etc/ssh/ca
cd /etc/ssh/ca
# User CA — signs user (client) certificates
sudo ssh-keygen -t ed25519 -f user_ca -C "CosmicBytez User CA" -N ""
# Host CA — signs host (server) certificates
sudo ssh-keygen -t ed25519 -f host_ca -C "CosmicBytez Host CA" -N ""Security note: In production, protect these private keys with strong passphrases (
-N ""is used here for automation clarity). Storeuser_caandhost_caprivate keys offline or in a hardware security module (HSM/YubiKey). The.pubfiles are safe to distribute widely.
Verify both keypairs were created:
ls -la /etc/ssh/ca/
# -rw------- user_ca (PRIVATE — guard this)
# -rw-r--r-- user_ca.pub (distribute to servers)
# -rw------- host_ca (PRIVATE — guard this)
# -rw-r--r-- host_ca.pub (distribute to clients)Step 2 — Sign the Host Certificate
Clients will use the host CA's public key to verify they're talking to a legitimate server — eliminating the "Are you sure you want to continue connecting?" TOFU prompt.
On the CA host, sign the SSH server's host public key:
# Copy the server's host public key to the CA host first
scp admin@ssh-server:/etc/ssh/ssh_host_ed25519_key.pub /tmp/ssh_host_ed25519_key.pub
# Sign it — valid for 52 weeks, hostname as principal
sudo ssh-keygen -s /etc/ssh/ca/host_ca \
-I "ssh-server.cosmicbytez.ca" \
-h \
-n "ssh-server.cosmicbytez.ca,ssh-server,10.10.1.50" \
-V +52w \
/tmp/ssh_host_ed25519_key.pub
# This creates: /tmp/ssh_host_ed25519_key-cert.pubFlag breakdown:
-s— path to the signing (CA) private key-I— certificate identity (appears in logs — use a meaningful hostname)-h— this is a host certificate (not a user certificate)-n— comma-separated list of valid principals (DNS names and IPs the cert is valid for)-V +52w— validity period (52 weeks; use a shorter TTL for higher-security environments)
Copy the signed certificate back to the SSH server:
scp /tmp/ssh_host_ed25519_key-cert.pub admin@ssh-server:/etc/ssh/On the SSH server, tell sshd to present this certificate:
# Add to /etc/ssh/sshd_config
echo "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub" | sudo tee -a /etc/ssh/sshd_configStep 3 — Configure Clients to Trust the Host CA
On each client machine, add the host CA to the global known_hosts so SSH automatically trusts signed servers without prompting:
# Download or copy the host CA public key
scp admin@ca-host:/etc/ssh/ca/host_ca.pub /tmp/host_ca.pub
# Add to system-wide known_hosts with @cert-authority marker
echo "@cert-authority *.cosmicbytez.ca,10.10.1.0/24 $(cat /tmp/host_ca.pub)" | \
sudo tee -a /etc/ssh/ssh_known_hostsAdjust the hostname pattern to match your domain. Now when a client connects to any server that presents a valid host certificate, SSH trusts it silently — no TOFU, no manual host key acceptance.
Step 4 — Sign User Certificates
User certificates replace (or complement) individual authorized_keys entries. With a short TTL, even a stolen private key becomes useless quickly.
On the CA host, sign a user's public key:
# The user sends you their public key — never their private key
# Example: ~/.ssh/id_ed25519.pub from admin user "dylan"
sudo ssh-keygen -s /etc/ssh/ca/user_ca \
-I "dylan@cosmicbytez.ca" \
-n "admin,ubuntu,ec2-user" \
-V -5m:+8h \
/path/to/dylan_id_ed25519.pubFlag breakdown:
-n "admin,ubuntu,ec2-user"— the Unix usernames this certificate can log in as (principals). The server enforces this.-V -5m:+8h— valid from 5 minutes ago (clock skew buffer) to 8 hours from now. Adjust TTL based on your risk tolerance.
The output is dylan_id_ed25519-cert.pub. Send it back to the user:
scp dylan_id_ed25519-cert.pub dylan@workstation:~/.ssh/id_ed25519-cert.pubThe user doesn't need to do anything special — if the certificate file is named <keyname>-cert.pub alongside the private key, SSH picks it up automatically:
ls ~/.ssh/
# id_ed25519 (private key — never leaves the workstation)
# id_ed25519.pub (public key — sent to CA for signing)
# id_ed25519-cert.pub (signed certificate — received from CA, expires in 8h)Step 5 — Configure Servers to Trust the User CA
On each SSH server, tell sshd to accept certificates signed by your user CA instead of (or in addition to) authorized_keys:
# Copy the user CA public key to the server
scp admin@ca-host:/etc/ssh/ca/user_ca.pub /tmp/user_ca.pub
sudo cp /tmp/user_ca.pub /etc/ssh/user_ca.pub
sudo chmod 644 /etc/ssh/user_ca.pubAdd the TrustedUserCAKeys directive to sshd_config:
echo "TrustedUserCAKeys /etc/ssh/user_ca.pub" | sudo tee -a /etc/ssh/sshd_configFor strict environments, you can also require that the certificate principal matches the target Unix account by adding an AuthorizedPrincipalsFile. Create /etc/ssh/auth_principals/%u per username:
sudo mkdir -p /etc/ssh/auth_principals
# For the "admin" Unix user, allow certificates with the "admin" principal
echo "admin" | sudo tee /etc/ssh/auth_principals/admin
echo "ubuntu" | sudo tee /etc/ssh/auth_principals/ubuntuThen enable it in sshd_config:
echo "AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u" | sudo tee -a /etc/ssh/sshd_configStep 6 — Harden sshd_config
With certificates in place, apply a comprehensive sshd_config hardening pass. Replace or append to /etc/ssh/sshd_config:
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak# /etc/ssh/sshd_config — hardened configuration
# ── Protocol & Network ────────────────────────────────────────────────────────
Port 22
AddressFamily inet
ListenAddress 0.0.0.0
# ── Host Keys — prefer modern algorithms ─────────────────────────────────────
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
# ── Cryptography — disable weak ciphers/MACs/KexAlgorithms ──────────────────
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
# ── Certificate Authority ─────────────────────────────────────────────────────
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
# ── Authentication ────────────────────────────────────────────────────────────
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys # keep for break-glass access
# Disable auth methods you're not using
GSSAPIAuthentication no
HostbasedAuthentication no
IgnoreRhosts yes
PermitEmptyPasswords no
# Root login — never allow direct root SSH
PermitRootLogin no
# Limit login attempts and grace time
MaxAuthTries 3
LoginGraceTime 30
# ── Session ───────────────────────────────────────────────────────────────────
# Disconnect idle sessions after 15 min
ClientAliveInterval 300
ClientAliveCountMax 3
# Limit concurrently unauthenticated connections (anti-scanner)
MaxStartups 10:30:60
# ── Access Control ────────────────────────────────────────────────────────────
# Restrict to specific group — add your admins to this group
AllowGroups sshusers
# ── Features — disable what you don't need ────────────────────────────────────
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
PrintMotd no
Banner /etc/ssh/banner
# ── Logging ───────────────────────────────────────────────────────────────────
LogLevel VERBOSE
SyslogFacility AUTHCreate the sshusers group and add your admin accounts:
sudo groupadd sshusers
sudo usermod -aG sshusers admin
sudo usermod -aG sshusers ubuntuCreate a login banner (optional but recommended for compliance):
sudo tee /etc/ssh/banner << 'EOF'
*******************************************************************************
WARNING: Authorized access only. All sessions are logged and monitored.
Unauthorized access is prohibited and may be subject to criminal prosecution.
*******************************************************************************
EOFValidate the configuration before reloading:
sudo sshd -t
# If no output, config is validReload sshd:
sudo systemctl reload sshdImportant: Keep your current session open until you've verified a new session connects successfully. Never reload sshd in a way that closes your only access path.
Step 7 — Automate Certificate Issuance (Optional)
For teams, manually signing certificates is friction. A simple wrapper script on the CA host makes issuance self-service with an approval step. Here's a minimal signing script you can wrap behind a bastion API or call over a VPN:
#!/usr/bin/env bash
# /usr/local/bin/sign-ssh-cert.sh
# Usage: sign-ssh-cert.sh <pubkey-file> <identity> <principals> <ttl>
set -euo pipefail
PUBKEY="${1:?pubkey required}"
IDENTITY="${2:?identity required}"
PRINCIPALS="${3:-admin}"
TTL="${4:-+8h}"
CA_KEY="/etc/ssh/ca/user_ca"
OUT_DIR="/tmp/signed-certs"
mkdir -p "$OUT_DIR"
CERT_OUT="$OUT_DIR/$(basename "$PUBKEY" .pub)-cert.pub"
ssh-keygen -s "$CA_KEY" \
-I "$IDENTITY" \
-n "$PRINCIPALS" \
-V "-5m:${TTL}" \
-z "$(date +%s)" \
"$PUBKEY"
echo "Certificate written to: ${PUBKEY%%.pub}-cert.pub"
echo "Valid principals: $PRINCIPALS"
echo "Expires: $TTL from now"The -z $(date +%s) flag sets a unique serial number on each certificate, making certificate revocation via RevokedKeys possible if needed.
Verification & Testing
Inspect a signed certificate
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pubExpected output:
/home/dylan/.ssh/id_ed25519-cert.pub:
Type: ssh-ed25519-cert-v01@openssh.com user certificate
Public key: ED25519-CERT SHA256:...
Signing CA: ED25519 SHA256:... (using ssh-ed25519)
Key ID: "dylan@cosmicbytez.ca"
Serial: 1723294812
Valid: from 2026-08-10T07:55:00 to 2026-08-10T16:00:00
Principals:
admin
ubuntu
Extensions:
permit-pty
permit-user-rc
Test the connection with verbose output
ssh -vvv -i ~/.ssh/id_ed25519 admin@ssh-server.cosmicbytez.ca 2>&1 | grep -E "(certificate|principal|Offering|Accepted)"Look for lines like:
debug1: Server host certificate: ssh-ed25519-cert-v01@openssh.com ...
debug1: Server host key: ssh-ed25519-cert-v01@openssh.com SHA256:...
debug1: Offering public key: ... ED25519-CERT ...
debug1: Server accepts key: ... ED25519-CERT ...
Verify sshd logs certificate auth events
sudo journalctl -u ssh --since "5 minutes ago" | grep -i cert
# Aug 10 08:00:01 ssh-server sshd[12345]: Accepted publickey for admin from 10.10.1.5 port 54321 ssh2: ED25519-CERT ID dylan@cosmicbytez.ca ...Test that expired certificates are rejected
Create a certificate with a 1-second TTL for testing:
ssh-keygen -s /etc/ssh/ca/user_ca \
-I "test-expiry" \
-n "admin" \
-V +1s \
~/.ssh/id_ed25519.pub
sleep 5
ssh -i ~/.ssh/id_ed25519 admin@ssh-server
# Received disconnect from ... : Certificate invalid: expiredConfirm weak cipher rejection
ssh -c aes128-cbc admin@ssh-server
# Unable to negotiate ... no matching cipher foundTroubleshooting
Permission denied (publickey) even with a valid certificate
- Check the certificate principals match the Unix username you're logging in as:
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub - Verify
TrustedUserCAKeyspoints to the correct file and the file is readable by sshd:sudo -u sshd cat /etc/ssh/user_ca.pub - If using
AuthorizedPrincipalsFile, confirm the file exists and contains the correct principal:cat /etc/ssh/auth_principals/admin
Host certificate not being presented
- Check
HostCertificateis insshd_configand points to the.pubfile (not the private key):grep HostCertificate /etc/ssh/sshd_config - Verify the certificate file is readable:
ls -la /etc/ssh/ssh_host_ed25519_key-cert.pub - Confirm the cert was signed with
-h(host flag):ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub | grep Type
SSH still prompts "Are you sure you want to continue connecting?"
The client isn't trusting the host CA. Double-check /etc/ssh/ssh_known_hosts on the client has the correct @cert-authority line and the hostname pattern matches the server you're connecting to.
sshd: no hostkeys available after config change
You removed a HostKey directive for a key that exists on disk. Add back the HostKey directive or generate the missing key: sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
Locked out after PermitRootLogin no
Ensure at least one non-root user in the sshusers group has a valid certificate or authorized_key. Always maintain a break-glass authorized_keys entry until certificate auth is confirmed working.
MaxStartups causing connection drops under load
Increase MaxStartups 10:30:100 or MaxStartups 50:30:100. The format is start:rate:full — 10:30:60 means: start refusing 30% of connections at 10 unauthenticated sessions, refuse all at 60.
Summary
You've hardened SSH across three layers:
| Layer | What you did | Security gain |
|---|---|---|
| Host certs | CA-signed server identity | Eliminates TOFU; clients verify servers cryptographically |
| User certs | Short-TTL signed user certs | Stolen keys expire fast; revocation is instant (stop signing) |
| sshd_config | Disabled weak crypto, root login, idle sessions | Dramatically reduces attack surface |
The CA model scales naturally: add a server to the fleet by dropping one TrustedUserCAKeys directive in its sshd_config — no per-user authorized_keys management required. Rotate the CA by generating a new keypair, distributing the new public key, and phasing out the old one as existing certificates expire.
For production environments, consider pairing this setup with HashiCorp Vault's SSH secrets engine, which acts as a managed CA and enforces TTLs and RBAC on who can request certificates for which principals — closing the loop on the signing workflow itself.