“Disable password authentication and require SSH keys” is the first thing every hardening checklist recommends, and it is correct as far as it goes. It is also, by 2026, the easy 20% of the problem. Public keys with no expiry, no revocation mechanism, and no centralized visibility into who has access to what are still a static-credential model — they just happen to be asymmetric-crypto credentials instead of passwords. A departing contractor’s key that nobody revoked, a developer laptop with ~/.ssh/id_ed25519 and no passphrase, or a key copied into a CI secret store that outlives the project it was created for: these are exactly as dangerous as a shared password that never rotates.

The next tier of SSH hardening addresses that gap directly: certificate-based authentication with short lifetimes, centralized bastions that give you a single audited chokepoint instead of dozens of directly-exposed hosts, and — increasingly, the pragmatic default for small teams — removing SSH from the public internet entirely by putting it behind a mesh VPN. None of this requires exotic tooling; OpenSSH has supported CA certificates natively since 6.0 (2012), and mesh VPN products like Tailscale and plain WireGuard have made “no port 22 open to the world” realistic for teams that used to consider it a nice-to-have.

This piece walks through each layer in the order you’d actually deploy them on a small self-hosted stack: a handful of VPS boxes behind Cloudflare, an internal admin network, and a team too small to run a dedicated PAM (privileged access management) product.

Why Static Keys Aren’t Enough

A raw public key authorized in ~/.ssh/authorized_keys has no expiry and no built-in way to answer “is this key still supposed to work?” without walking every host and diffing the file. Revocation means SSH-ing into every server (using the same keys you’re trying to audit) and manually deleting a line. At scale this either doesn’t happen reliably, or it requires configuration management (Ansible, Puppet) to push authorized_keys centrally — which is a reasonable stopgap but still leaves you with long-lived, non-expiring credentials as the trust anchor.

Certificates fix this by inverting the trust model: instead of each host trusting a list of individual public keys, each host trusts a Certificate Authority. The CA signs short-lived certificates for users (and separately for hosts), and any host or client configured to trust that CA automatically accepts any certificate the CA has signed — no per-host authorized_keys editing required, and no long-lived credential to leak, because the certificate itself expires in hours, not years.

Setting Up an SSH CA for User Certificates

# 1. Generate the CA keypair once, store the private key offline / in a vault
ssh-keygen -t ed25519 -f ssh_user_ca -C "furybee-user-ca"
# ssh_user_ca        <- CA private key, NEVER distributed to hosts
# ssh_user_ca.pub    <- CA public key, distributed to every server

# 2. On every server, add to /etc/ssh/sshd_config, then reload sshd:
#   TrustedUserCAKeys /etc/ssh/ssh_user_ca.pub
#   AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
#   PasswordAuthentication no
#   KbdInteractiveAuthentication no
#   PubkeyAuthentication yes
#   PermitRootLogin no

AuthorizedPrincipalsFile maps certificate “principals” (roles baked into the cert, like deploy or oncall) to which local Unix users they may log in as, so one CA can safely serve many hosts with different access levels without maintaining per-host key lists.

Issuing a short-lived certificate for a user, after they authenticate to your identity provider (SSO, a bastion-side approval step, whatever your access workflow is):

# Sign a certificate valid for 8 hours, restricted to the "deploy" principal
ssh-keygen -s ssh_user_ca -I "sebastien-2026-07-28" \
  -n deploy,oncall \
  -V +8h \
  -O source-address=203.0.113.0/24 \
  ~/.ssh/id_ed25519.pub

That produces id_ed25519-cert.pub, which the client presents alongside the private key it already had. Eight hours later the certificate is worthless, with no revocation step required on any server — expiry does the work that manual key deletion used to.

Host Certificates: Fixing the “Is This Really the Server?” Problem

The flip side of user certificates is host certificates, which solve a problem most teams just live with: the first-connection TOFU (trust on first use) prompt — “The authenticity of host ‘x.x.x.x’ can’t be established” — that everyone reflexively types yes to, defeating the entire point of host key verification.

# Sign the host key once, on each server, using a separate host CA
ssh-keygen -s ssh_host_ca -I "web-01.internal" -h \
  -n web-01.internal,web-01 \
  -V +52w \
  /etc/ssh/ssh_host_ed25519_key.pub

Clients then trust the host CA instead of accumulating individual known_hosts entries — add one @cert-authority line to ~/.ssh/known_hosts (or org-wide, to /etc/ssh/ssh_known_hosts): @cert-authority *.internal.furybee.org ssh-ed25519 AAAA...ssh_host_ca_pubkey....

Now a new server joining the fleet is trusted automatically the moment its host certificate is signed — no more blind yes prompts, and a real defense against on-path MITM against a first connection.

Bastions: One Audited Chokepoint Instead of Many Exposed Hosts

Even with certificates, exposing SSH on every host to the internet multiplies your attack surface for no benefit. A bastion (jump host) is the single machine allowed to accept SSH from outside, and every other host only accepts SSH from the bastion’s internal IP.

# ~/.ssh/config on a developer laptop
Host bastion
    HostName bastion.furybee.org
    User sebastien
    IdentityFile ~/.ssh/id_ed25519
    CertificateFile ~/.ssh/id_ed25519-cert.pub

Host web-* db-* cache-*
    ProxyJump bastion
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    CertificateFile ~/.ssh/id_ed25519-cert.pub

ProxyJump (the modern replacement for the old ProxyCommand nc dance) makes multi-hop access transparent: ssh web-01 silently tunnels through bastion first. On the bastion itself, restrict what can reach internal hosts and lock the box down harder than anything behind it — it is the single highest-value target in the design, so it deserves 2FA on top of certificates, aggressive fail2ban, and nothing else running on it.

# /etc/ssh/sshd_config on the bastion
AllowTcpForwarding yes
PermitTunnel no
X11Forwarding no
AuthenticationMethods publickey,keyboard-interactive

AuthenticationMethods publickey,keyboard-interactive forces both a valid certificate and a second factor (TOTP via pam_google_authenticator, or a hardware token via pam_u2f) before the bastion lets a session through — genuine 2FA on the one host that matters most, without slowing down every internal hop behind it.

Session Recording on the Bastion

A bastion is also the natural place to record what operators actually did, independent of anything a compromised or malicious client might try to hide.

# Wrap the login shell to record every bastion session with asciinema
# /etc/ssh/sshd_config: ForceCommand /usr/local/bin/record-session.sh

cat /usr/local/bin/record-session.sh
#!/bin/bash
SESSION_LOG="/var/log/ssh-sessions/${USER}-$(date +%Y%m%d-%H%M%S)-$$.cast"
exec asciinema rec -q "$SESSION_LOG" -c "$SHELL"

Ship those .cast files off-box (to object storage, or a log aggregator) on a timer, so a compromised bastion can’t be used to also erase the evidence of what happened on it.

Comparing the Options

ApproachWhat it solvesWhat it doesn’t solve
SSH keys, no passwordsRemoves brute-forceable password authLong-lived credentials, no revocation, no centralized audit
SSH CA certificatesShort-lived credentials, centralized trust, principal-based authzStill requires SSH reachable somewhere (the bastion)
Bastion / jump hostSingle audited chokepoint, session recording, easier network ACLsThe bastion itself becomes the highest-value target
Mesh VPN (Tailscale/WireGuard)Removes SSH from the public internet entirely; device-level authAdds a dependency on the mesh control plane being up

Replacing Public SSH Exposure With a Mesh VPN

For a small team, the most effective change is often the simplest one: stop exposing port 22 to the internet at all. A mesh VPN like Tailscale (built on WireGuard) gives every authorized device a stable internal IP reachable only after it authenticates to the mesh’s own identity provider (often the same SSO you already use for everything else), and SSH then only needs to listen on that mesh interface.

# On each server: install and join the tailnet
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --ssh --accept-routes

Tailscale’s built-in --ssh flag can even replace OpenSSH’s own auth entirely for simple cases, authenticating purely off tailnet identity — but running your own sshd bound only to the tailscale0 interface (add ListenAddress 100.64.0.0 — an example CGNAT-range tailnet address — to /etc/ssh/sshd_config), still hardened with certificates as above, keeps you independent of a single vendor’s control plane.

If you’d rather not depend on a hosted control plane at all, plain WireGuard achieves the same network-level result with more manual key management:

# /etc/wireguard/wg0.conf on a server
[Interface]
Address = 10.10.0.2/24
PrivateKey = <server-private-key>
ListenPort = 51820

[Peer]
PublicKey = <admin-laptop-public-key>
AllowedIPs = 10.10.0.10/32

Either way, the effect on your attack surface is dramatic: internet-wide SSH scanners (and there are always scanners hitting port 22 within minutes of a new IP going live) simply find nothing to connect to. Combined with CA certificates and a bastion for the cases where you still want a shared internal chokepoint, you get defense in depth instead of a single control being the entire strategy.

Key Takeaways

  • Disabling password authentication is table stakes, not the finish line — static SSH keys with no expiry are still long-lived credentials with poor revocation.
  • SSH CA certificates give you short-lived, centrally issued credentials and principal-based authorization without editing authorized_keys on every host.
  • Host certificates eliminate the reflexive “yes” to unknown host key prompts, closing a real (if often ignored) MITM window on first connection.
  • A bastion consolidates SSH exposure into one host you can harden aggressively, enforce real 2FA on, and record sessions from — but it becomes your highest-value target, so treat it accordingly.
  • ProxyJump makes bastion hops transparent to users; there’s no operational reason to still be using manual nc-based ProxyCommand tricks.
  • For small teams, a mesh VPN (Tailscale or plain WireGuard) removing SSH from the public internet entirely is often the highest-leverage single change you can make.
  • Layer these controls — certificates, bastion, mesh VPN, session recording — rather than picking one; each closes a gap the others leave open.