SSH Server Hardening in 2026: A Version-Aware sshd_config Guide

An SSH configuration can pass sshd -t and still lock out every administrator. Check effective settings, change only necessary controls, and keep a tested recovery console available until a fresh login succeeds.

These are Linux/Bash examples. Verify the server package version: client versions, executable paths, PAM, service units, and socket activation can differ.

Correction — September 18, 2026: Fixed both case-sensitive configuration filters and the pre-sudo key-file check. The replacement scanner is bounded and privilege-aware. Validation uses offline fixtures, not live-host authentication tests.

On this page
  1. Read the Effective Configuration First
  2. Configure Key-Only Authentication Without a PAM Back Door
  3. Restrict Accounts Before Tuning Cryptography
  4. Do Not Freeze a Modern Server to an Old Algorithm List
  5. Dead-Client Detection Is Not an Idle-Session Timeout
  6. Audit Every Authorized-Key Source
  7. Apply Changes Without Locking Yourself Out
  8. Related Access Controls
  9. Frequently Asked Questions
  10. Why can a lowercase sshd configuration filter miss settings?
  11. Why must the entire authorized-key scanner have the required privileges?
  12. Do SSH keepalives enforce a user inactivity timeout?
  13. Does a successful syntax check prove that a new login will work?

Read the Effective Configuration First#

Include files and Match rules matter. OpenSSH generally uses the first obtained value, so an early vendor drop-in can win over a later edit. OpenSSH 10.5, released August 11, 2026, documents mixed-case configuration dumps. Use grep -Ei, not lowercase case-sensitive filters.

Adjust the executable path if your Linux package installs the daemon elsewhere:

set -o pipefail
sudo /usr/sbin/sshd -t &&
sudo /usr/sbin/sshd -T | grep -Ei \
'^(permitrootlogin|pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|authorizedkeysfile|authorizedkeyscommand|authorizedkeyscommanduser|trustedusercakeys|allowgroups|allowusers|ciphers|kexalgorithms|macs|channeltimeout)[[:space:]]'

The sshd manual distinguishes -t syntax/host-key checks from -T effective output. Add representative -C connection parameters for Match: user=admin-account,addr=192.0.2.10,host=client.example are documentation placeholders. Keep stderr visible; stop on errors or unexpected empty output and inspect the unfiltered dump. Filtering is not a compliance test.

Configure Key-Only Authentication Without a PAM Back Door#

For public-key-only servers without keyboard-interactive MFA:

PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
PermitRootLogin no

Disabling password authentication alone leaves possible PAM-backed keyboard-interactive passwords. Intentional MFA instead needs its reviewed authentication chain, such as publickey,keyboard-interactive:pam. Do not casually disable UsePAM; it also handles account/session policy.

Prefer named administrators with sudo. Upstream PermitRootLogin prohibit-password still permits root keys; the example deliberately uses no. Check vendor overrides in the effective configuration.

Restrict Accounts Before Tuning Cryptography#

Create the allowlist group and add every required administrator before enabling it:

AllowGroups sshusers
MaxAuthTries 4
LoginGraceTime 30
LogLevel VERBOSE

Test automation and emergency accounts. Multiple agent keys can exhaust authentication attempts; consider per-host IdentitiesOnly yes. Verify log destinations. Moving port 22 is not access control: firewall, NAT, SELinux, and socket-activated listeners need separate checks.

Do Not Freeze a Modern Server to an Old Algorithm List#

Replacement algorithm lists can discard newer defaults, including hybrid post-quantum key exchange. Compare client capabilities with server output:

ssh -Q cipher
ssh -Q kex
ssh -Q mac
set -o pipefail
sudo /usr/sbin/sshd -T | grep -Ei '^(ciphers|kexalgorithms|macs)[[:space:]]'

ssh -Q describes that client, not the server. Keep patched distribution defaults unless policy requires a tested change; prefer supported list modifiers over replacing complete lists.

Dead-Client Detection Is Not an Idle-Session Timeout#

ClientAliveInterval and ClientAliveCountMax detect unresponsive clients, not idle users. OpenSSH 9.2 introduced channel inactivity limits:

ChannelTimeout session=10m

Traffic resets this timer; quiet shells, commands, SCP, and SFTP can be interrupted. Closing a channel does not guarantee child-process cleanup. 9.7 added global timeouts; 10.5 fixed application inside Match. Check backports, syntax, and actual runtime behavior before adopting the policy.

Audit Every Authorized-Key Source#

The old [ -f "$file" ] check ran before sudo and could silently skip protected directories. Linux path resolution distinguishes permission denial from absence.

Review and save audit_authorized_keys.py in an administrator-controlled directory. This Linux/Python 3.10+ scanner requires root before lookup, accepts 1–25 explicit accounts, and reads only two default key paths. It refuses symlinks/non-regular files, caps each file at 1 MiB, and limits each ssh-keygen subprocess to ten seconds. The referenced 10.5p1 implementation supports stdin input. No permissions or configuration are changed.

#!/usr/bin/env python3
import os
import pwd
import stat
import subprocess
import sys

MAX_ACCOUNTS = 25
MAX_BYTES = 1024 * 1024
KEYGEN = "/usr/bin/ssh-keygen"
RELATIVE_FILES = (".ssh/authorized_keys", ".ssh/authorized_keys2")


def read_bounded(path):
    parts = [part for part in path.split("/") if part]
    if not path.startswith("/") or not parts or any(
        part in (".", "..") for part in parts
    ):
        raise ValueError("requires an absolute path without dot components")
    directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
    directory = os.open("/", directory_flags)
    try:
        for part in parts[:-1]:
            child = os.open(part, directory_flags, dir_fd=directory)
            os.close(directory)
            directory = child
        fd = os.open(
            parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
            dir_fd=directory,
        )
    finally:
        os.close(directory)
    with os.fdopen(fd, "rb") as stream:
        info = os.fstat(stream.fileno())
        if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_BYTES:
            raise ValueError("not a regular file or exceeds the 1 MiB limit")
        data = stream.read(MAX_BYTES + 1)
    if len(data) > MAX_BYTES:
        raise ValueError("file grew beyond the 1 MiB limit")
    return data


def audit_file(user, path):
    label = f"{user!r} {path!r}"
    try:
        data = read_bounded(path)
    except FileNotFoundError:
        print(f"MISSING {label} (file or path component absent)")
        return 0
    except (OSError, ValueError) as exc:
        print(f"ERROR {label}: {exc!r}", file=sys.stderr)
        return 1
    if not any(
        line.strip() and not line.lstrip().startswith(b"#")
        for line in data.splitlines()
    ):
        print(f"EMPTY {label} (no non-comment entries)")
        return 0
    try:
        result = subprocess.run(
            [KEYGEN, "-l", "-E", "sha256", "-f", "-"],
            input=data, capture_output=True, timeout=10, check=False,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        print(f"ERROR {label}: {exc!r}", file=sys.stderr)
        return 1
    if result.returncode or result.stderr:
        print(
            f"ERROR {label}: ssh-keygen status={result.returncode}, "
            f"stderr={result.stderr!r}", file=sys.stderr,
        )
        return 1
    print(f"FINGERPRINTS {label}")
    for line in result.stdout.decode("utf-8", errors="replace").splitlines():
        print(ascii(line))
    return 0


def main(users):
    if sys.platform != "linux" or sys.version_info < (3, 10):
        print("ERROR: requires Linux and Python 3.10+.", file=sys.stderr)
        return 2
    if os.geteuid() != 0:
        print("ERROR: run the entire reviewed scanner as root.", file=sys.stderr)
        return 2
    if not 1 <= len(users) <= MAX_ACCOUNTS:
        print("ERROR: supply 1-25 explicit account names.", file=sys.stderr)
        return 2
    failed = 0
    for user in dict.fromkeys(users):
        try:
            home = pwd.getpwnam(user).pw_dir
            if not home.startswith("/"):
                raise ValueError("account home must be absolute")
        except (KeyError, OSError, ValueError) as exc:
            print(f"ERROR account {user!r}: {exc!r}", file=sys.stderr)
            failed = 1
            continue
        for relative in RELATIVE_FILES:
            failed |= audit_file(user, f"{home.rstrip('/')}/{relative}")
    return failed


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))

Replace the placeholder with authorized account names; elevate the whole reviewed scanner:

sudo /usr/bin/python3 -I ./audit_authorized_keys.py admin-account

MISSING reports absent path components; EMPTY means blank/comment-only content. ERROR is nonzero and requires investigation, including root-squashed storage. Output is sensitive inventory, not proof of access or revocation.

NSS/kernel I/O is outside the subprocess timeout. Review symlink mappings separately. ssh-keygen can ignore malformed lines alongside valid keys. Audit custom AuthorizedKeysFile paths/tokens, AuthorizedKeysCommand and its user, trusted user CAs, and revocation controls separately; the default-file inventory cannot cover them.

Apply Changes Without Locking Yourself Out#

  1. Test recovery-console access; retain the existing SSH session.
  2. Back up configuration, make minimal edits, and check -t and representative -T -C results.
  3. Reload the installed service; follow distribution-specific socket procedures where applicable.
  4. Use a fresh, non-multiplexed connection. Test the allowed key, denied accounts, password-only attempts, and intended keyboard-interactive behavior before closing the backup session.

For adjacent controls, see hardware-backed SSH keys and homelab remote access.

Frequently Asked Questions#

Why can a lowercase sshd configuration filter miss settings?#

OpenSSH 10.5 emits mixed-case names. Use grep -Ei and investigate missing output or command failures.

Why must the entire authorized-key scanner have the required privileges?#

An unprivileged existence check can skip protected files before sudo runs. Check privileges before account or file lookup.

Do SSH keepalives enforce a user inactivity timeout?#

No. ClientAlive detects unresponsive clients; ChannelTimeout concerns channel traffic. Test version support and quiet workloads separately.

Does a successful syntax check prove that a new login will work?#

No. Keep the recovery console and backup session available, then test fresh successful and denied authentication paths.

Before changing a remote server, confirm that its recovery console actually works.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *