{"id":359,"date":"2026-10-07T06:14:37","date_gmt":"2026-10-07T13:14:37","guid":{"rendered":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/07\/ssh-hardening-beyond-disable-root-login\/"},"modified":"2026-10-20T20:23:08","modified_gmt":"2026-10-21T03:23:08","slug":"ssh-hardening-beyond-disable-root-login","status":"publish","type":"post","link":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/07\/ssh-hardening-beyond-disable-root-login\/","title":{"rendered":"SSH hardening beyond &#8220;disable root login&#8221;"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><em>Part 1 of 4 in the Hardening a Linux server series.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every hardening guide starts with <code>PermitRootLogin no<\/code>, and plenty of them stop there. That setting is fine. It&#8217;s also the least interesting thing about securing SSH, because an attacker who can log in as a sudo-capable user with a password has root anyway. The things that actually decide how exposed your SSH daemon is are how users prove who they are, how the server proves who it is, which crypto you still accept, and who is allowed to try at all.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Start from what sshd is really doing<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"760\" src=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/ssh-hardening-beyond-disable-root-login-diagram.png\" alt=\"Sequence diagram: laptop sends a public key to an SSH CA, receives an 8 hour certificate, presents it to sshd, which checks the CA signature, principals, revocation list and validity before granting the session\" class=\"wp-image-357\" srcset=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/ssh-hardening-beyond-disable-root-login-diagram.png 1200w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/ssh-hardening-beyond-disable-root-login-diagram-300x190.png 300w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/ssh-hardening-beyond-disable-root-login-diagram-1024x649.png 1024w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/ssh-hardening-beyond-disable-root-login-diagram-768x486.png 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Before changing anything, look at the effective configuration rather than the file. Distributions now ship <code>Include \/etc\/ssh\/sshd_config.d\/*.conf<\/code> near the top of <code>sshd_config<\/code>, and for most keywords sshd uses the first value it reads, not the last. I&#8217;ve watched people append <code>PasswordAuthentication no<\/code> to the bottom of the main file while a cloud-init drop-in earlier in the include order said <code>yes<\/code>, and the drop-in won.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo sshd -T | grep -Ei 'passwordauth|kbdinteractive|permitroot|pubkeyauth|authenticationmethods'\nsudo sshd -T -C user=deploy,host=10.0.0.5,addr=10.0.0.5<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The second form evaluates <code>Match<\/code> blocks for a specific connection, which is the only sane way to test conditional rules. Put your own settings in a drop-in with a low number, like <code>\/etc\/ssh\/sshd_config.d\/00-hardening.conf<\/code>, so they load first.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Keys, and better keys<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Passwords on an internet-facing sshd are a credential-stuffing target forever. Turn off both password paths, since keyboard-interactive will happily do password auth through PAM even when <code>PasswordAuthentication<\/code> is off:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>PasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\nPermitRootLogin no\nMaxAuthTries 3\nLoginGraceTime 20<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">(<code>KbdInteractiveAuthentication<\/code> replaced <code>ChallengeResponseAuthentication<\/code> in OpenSSH 8.7. Older configs use the old name, which is still accepted as an alias.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For user keys, Ed25519 is the default choice now. The bigger upgrade is hardware-backed keys. Since OpenSSH 8.2 you can generate <code>ed25519-sk<\/code> keys that live on a FIDO2 token, so the private key file on disk is useless without the physical device and a touch:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh-keygen -t ed25519-sk -O resident -O verify-required -C \"alice@laptop\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>verify-required<\/code> (OpenSSH 8.4+) demands a PIN as well as a touch. Malware on a laptop can steal an ordinary key file in a second. With an <code>-sk<\/code> key it has to wait for the user to tap the token at the right moment, which is a much harder problem.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Certificates instead of authorized_keys sprawl<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The real weakness of plain public keys is lifecycle. <code>authorized_keys<\/code> files accumulate keys from people who left two years ago, and nobody can say which key belongs to whom. OpenSSH has had its own certificate format since 5.4, and it&#8217;s underused. A CA signs a user&#8217;s public key with an identity, a list of principals and a validity window:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh-keygen -s user_ca -I alice@example.com -n alice,deploy -V +8h id_ed25519.pub<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The server trusts the CA rather than individual keys:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>TrustedUserCAKeys \/etc\/ssh\/user_ca.pub\nAuthorizedPrincipalsFile \/etc\/ssh\/principals\/%u\nRevokedKeys \/etc\/ssh\/revoked_keys<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>\/etc\/ssh\/principals\/deploy<\/code> lists which principals may log in as <code>deploy<\/code>. Now offboarding is mostly automatic, because certificates expire in hours, and the <code>-I<\/code> identity shows up in the auth log so you know which human used the shared account. Keep an emergency revocation path anyway (<code>ssh-keygen -k<\/code> builds a key revocation list), and protect the CA key like the crown jewels it is, ideally in a hardware token or a signing service.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Host certificates solve the other half. Sign host keys with <code>ssh-keygen -h<\/code>, add one <code>@cert-authority *.example.com<\/code> line to clients&#8217; <code>known_hosts<\/code>, and the &#8220;authenticity of host can&#8217;t be established&#8221; prompt that everyone trains themselves to click through goes away. Trust on first use is a bet that the first connection wasn&#8217;t intercepted. Certificates remove the bet.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Prune the crypto, but don&#8217;t fight the defaults<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Modern OpenSSH defaults are good, and hand-written cipher lists copied from 2016 blog posts are often worse than the defaults they replace. OpenSSH 8.8 already disabled <code>ssh-rsa<\/code> signatures using SHA-1. OpenSSH 9.0 made the hybrid post-quantum <code>sntrup761x25519-sha512@openssh.com<\/code> key exchange the default, and 10.0 moved to <code>mlkem768x25519-sha256<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Where explicit lists still pay off is when a specific weakness lands. Terrapin (CVE-2023-48795) was a prefix-truncation attack against the SSH transport that worked when <code>chacha20-poly1305@openssh.com<\/code>, or a CBC cipher paired with an encrypt-then-MAC MAC, was negotiated. The fix was &#8220;strict kex&#8221; in OpenSSH 9.6, which needs both sides patched. Until clients caught up, removing the affected algorithms was a reasonable stopgap. Audit what you expose with ssh-audit rather than guessing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ssh-audit -p 22 server.example.com<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Decide who gets to try<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Authentication strength matters less if most accounts can&#8217;t authenticate at all. An allowlist is short and readable:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>AllowGroups ssh-users\nAllowTcpForwarding no\nAllowAgentForwarding no\nAllowStreamLocalForwarding no\nX11Forwarding no\n\nMatch Group ssh-admins Address 10.20.0.0\/16\n    AllowTcpForwarding yes\n\nMatch User backup\n    ForceCommand \/usr\/local\/bin\/backup-receive\n    PermitTTY no<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Forwarding is how an SSH foothold turns into a pivot into the rest of the network, so default it off and turn TCP forwarding back on only for admins coming from the internal range. There&#8217;s also <code>DisableForwarding yes<\/code> (OpenSSH 7.4+), which kills every kind of forwarding in one line, but it overrides the individual switches, so a <code>Match<\/code> block can&#8217;t selectively re-enable one of them without also setting <code>DisableForwarding no<\/code> there. Explicit switches are easier to reason about.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Network placement still matters. Port knocking and moving sshd to port 2222 cut log noise, nothing more. Putting sshd behind a VPN or a bastion, or at least restricting port 22 to known address ranges at the firewall, actually shrinks who can reach the parser. And the parser has had bad days. regreSSHion (CVE-2024-6387) was a signal-handler race in sshd on glibc Linux that allowed unauthenticated remote code execution, affecting OpenSSH 8.5p1 through 9.7p1 until 9.8p1 fixed it. The temporary mitigation was <code>LoginGraceTime 0<\/code>, which trades RCE risk for an easy connection-exhaustion DoS. The xz backdoor (CVE-2024-3094) showed that sshd&#8217;s attack surface includes whatever libraries your distribution links into it. Nobody&#8217;s config file would have stopped either of those. Reachability would have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">OpenSSH 9.8 also added <code>PerSourcePenalties<\/code>, which is on by default and makes sshd refuse connections for a while from addresses that repeatedly fail auth or crash a child. It&#8217;s a built-in, rough fail2ban.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Log enough to answer questions later<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Set <code>LogLevel VERBOSE<\/code>. At that level sshd logs the fingerprint of the key that authenticated each session, and for certificates the key ID and serial. Without it, &#8220;which key did this login use?&#8221; has no answer. Part 3 of this series covers auditd and shipping those logs somewhere an intruder can&#8217;t edit them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What I&#8217;d actually do on a new server, in order: allowlist users, kill passwords, move people to certificates, keep forwarding off, limit reachability, and patch sshd quickly when the next CVE lands. Disabling root login comes for free along the way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Other parts of the Hardening a Linux server series: <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/08\/least-privilege-with-systemd-sandboxing\/\">Part 2: Least privilege with systemd sandboxing<\/a>, <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/09\/auditd-what-to-log-for-investigations\/\">Part 3: auditd and what to log<\/a>, <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/12\/patching-unattended-upgrades-live-patching-reboots\/\">Part 4: Patching without fear<\/a>.<\/em><\/p>\n\n","protected":false},"excerpt":{"rendered":"<p>Part 1 of 4 in the Hardening a Linux server series. Every hardening guide starts with PermitRootLogin no, and plenty of them stop there. That setting is fine. It&#8217;s also the least interesting thing about securing SSH, because an attacker who can log in as a sudo-capable user with a password has root anyway. The [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":360,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[22,21],"tags":[87,88,85,84,86],"class_list":["post-359","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-linux","category-security","tag-fido2","tag-hardening-a-linux-server-series","tag-openssh","tag-ssh","tag-ssh-certificates"],"_links":{"self":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/359","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/comments?post=359"}],"version-history":[{"count":1,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/359\/revisions"}],"predecessor-version":[{"id":392,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/359\/revisions\/392"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media\/360"}],"wp:attachment":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media?parent=359"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/categories?post=359"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/tags?post=359"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}