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
- Read the Effective Configuration First
- Configure Key-Only Authentication Without a PAM Back Door
- Restrict Accounts Before Tuning Cryptography
- Do Not Freeze a Modern Server to an Old Algorithm List
- Dead-Client Detection Is Not an Idle-Session Timeout
- Audit Every Authorized-Key Source
- Apply Changes Without Locking Yourself Out
- Related Access Controls
- Frequently Asked Questions
- Why can a lowercase sshd configuration filter miss settings?
- Why must the entire authorized-key scanner have the required privileges?
- Do SSH keepalives enforce a user inactivity timeout?
- 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#
- Test recovery-console access; retain the existing SSH session.
- Back up configuration, make minimal edits, and check
-tand representative-T -Cresults. - Reload the installed service; follow distribution-specific socket procedures where applicable.
- 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.
Related Access Controls#
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.
Leave a Reply