SSH keys are convenient, and convenience is exactly why they get out of hand. The practical discipline is not to generate fewer keys; it is to make each key a named identity with a clear scope, a short lifetime, and a record in the files that tell an auditor who is allowed to do what.
A key should read as an identity
An SSH key should read as an identity, not as a passwordless convenience. Name it for the owner and purpose rather than the laptop it happens to sit on, set a short comment, and put a passphrase on the private key where the toolchain supports it, so possession of the file alone is not enough. A key whose name tells you whom it belongs to and what it exists for lets a future operator apply least privilege without guessing, which is the difference between a scoped credential and an anonymous key graveyard.
Scope is the point
Scope is the real point of a key. Prefer a key that can reach only what the role needs: on the server, restrict which keys are accepted and what each may do through the account and the authorized_keys options, pinning shell or command access; for automation, pin the commands or the working directory it may run in. A compromised key is then contained by its scope rather than being a master key, so the damage stays where the key was allowed to act.
Rotation and the audit trail
Old keys never disappear by themselves, so rotation is what keeps the list honest. Remove keys whose operators have moved on, reissue with a shorter lifetime, and periodically confirm that every accepted key still matches an intended human or automaton and its intended scope. The authorized_keys file is the concrete, verifiable expression of that policy, and a scheduled review of it is cheap insurance, since an outdated key rarely announces itself and a bad one never knocks.
When it breaks
A passphrase-less private key turns every copy into a login. Cloned laptops, shared dotfiles, and cloud backups all carry ~/.ssh; anyone reading such a copy authenticates immediately, indistinguishable from the owner in the server log. No prompt ever fires, so the failure stays silent until an unfamiliar address appears.
Stale authorized_keys entries fail just as quietly. Engineers leave and cron jobs retire, yet the public key stays because nobody owns the cleanup. Months later that forgotten line is still valid, usually with no from= or command= bounding it. Rotation that only adds keys widens the attack surface every cycle.
Legacy algorithms are the third failure. ssh-dss and short ssh-rsa still authenticate against lenient servers, though guidance moved to Ed25519 long ago. Success pages nobody, while each captured handshake gives an attacker more leverage over the weaker primitive. Generate a modern key and delete the old line instead of keeping both.
The fourth is agent forwarding hijack. ssh -A exposes the local agent socket on the remote host, and anyone privileged there can ask it to sign challenges while the session lives. The key file never moves, yet logins fan out as the owner. The symptom is lateral movement with no stolen-key artifact, only signatures from the open forwarding.
Check it quickly
Save the following as ssh_audit.py and run python3 ssh_audit.py. It uses only the standard library, reads the current user’s ~/.ssh, and changes nothing. Hosts without SSH material report SKIP, so it runs cleanly on fresh machines.
"""Audit local SSH key hygiene: permissions, authorized_keys options, algorithms.
Read-only. Uses only the Python standard library.
Missing files are reported as SKIP, not failures.
"""
from __future__ import annotations
import stat
from pathlib import Path
WEAK_KEY_TYPES = {"ssh-rsa", "ssh-dss"}
KNOWN_KEY_TYPES = {
"ssh-rsa",
"ssh-dss",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-ed25519",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
}
RESTRICTING_OPTIONS = (
"command=",
"from=",
"restrict",
"principals=",
"no-agent-forwarding",
)
# Files in ~/.ssh that are never private keys.
SKIP_NAMES = {
"known_hosts",
"known_hosts.old",
"config",
"authorized_keys",
"environment",
}
def mode_of(path: Path) -> str:
return oct(stat.S_IMODE(path.stat().st_mode))
def split_options(field: str) -> list[str]:
"""Split an authorized_keys option field on commas outside quotes."""
options, current, quoted = [], [], False
for char in field:
if char == '"':
quoted = not quoted
current.append(char)
elif char == "," and not quoted:
options.append("".join(current))
current = []
else:
current.append(char)
if current:
options.append("".join(current))
return options
def parse_authorized_key(line: str, lineno: int) -> dict:
parts = line.split()
if parts[0] in KNOWN_KEY_TYPES:
keytype, options = parts[0], []
else:
options = split_options(parts[0])
keytype = parts[1] if len(parts) > 1 else "<unparsable>"
restricted = any(
opt.strip().lower().startswith(RESTRICTING_OPTIONS) for opt in options
)
return {"lineno": lineno, "type": keytype, "restricted": restricted}
def main() -> int:
ssh_dir = Path.home() / ".ssh"
warnings = 0
checked_keys = 0
if not ssh_dir.is_dir():
print(f"SSH dir {ssh_dir}: SKIP (no .ssh directory)")
print("RESULT: SKIP - nothing to audit")
return 0
dir_mode = mode_of(ssh_dir)
dir_flag = "OK" if dir_mode == "0o700" else "WARN (expected 0o700)"
if dir_flag.startswith("WARN"):
warnings += 1
print(f"SSH dir {ssh_dir}: mode {dir_mode} {dir_flag}")
for child in sorted(ssh_dir.iterdir()):
if not child.is_file() or child.suffix == ".pub":
continue
if child.name in SKIP_NAMES or child.name.startswith("."):
continue
head = child.read_bytes()[:64]
looks_like_key = (
b"PRIVATE KEY" in head
or child.name.startswith("id_")
or child.name.endswith((".pem", ".key"))
)
if not looks_like_key:
continue
file_mode = mode_of(child)
exposed = bool(stat.S_IMODE(child.stat().st_mode) & 0o077)
flag = "OK" if not exposed else f"WARN (mode {file_mode} is group/other-readable)"
if exposed:
warnings += 1
print(f"private key {child.name}: mode {file_mode} {flag}")
auth_keys = ssh_dir / "authorized_keys"
if not auth_keys.is_file():
print(f"{auth_keys}: SKIP (no authorized_keys file)")
else:
ak_mode = mode_of(auth_keys)
ak_flag = "OK" if ak_mode in ("0o600", "0o400") else f"WARN (mode {ak_mode}, expected 0o600)"
if ak_flag.startswith("WARN"):
warnings += 1
print(f"authorized_keys: mode {ak_mode} {ak_flag}")
for lineno, raw in enumerate(
auth_keys.read_text(encoding="utf-8").splitlines(), 1
):
line = raw.strip()
if not line or line.startswith("#"):
continue
checked_keys += 1
entry = parse_authorized_key(line, lineno)
notes = []
if entry["type"] in WEAK_KEY_TYPES:
notes.append("legacy algorithm")
if entry["type"] == "<unparsable>":
notes.append("could not parse key type")
if not entry["restricted"]:
notes.append("no restriction (no command=/from=/restrict)")
if notes:
warnings += 1
print(
f"authorized_keys line {lineno}: "
f"{entry['type']} WARN ({'; '.join(notes)})"
)
else:
print(
f"authorized_keys line {lineno}: "
f"{entry['type']} OK (restricted)"
)
print("passphrase: NOT CHECKED here (use `ssh-keygen -y -f <key>` separately)")
if checked_keys == 0:
print(f"RESULT: no authorized_keys entries checked; warnings={warnings}")
else:
print(f"RESULT: {checked_keys} authorized_keys entries checked; warnings={warnings}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The script checks three things. First, modes: ~/.ssh should be 0o700, private keys 0o600 or stricter, authorized_keys 0o600 or 0o400. Second, it parses each authorized_keys line, splitting quoted command="..." correctly, and flags entries lacking command=, from=, or restrict. Third, it flags legacy ssh-rsa and ssh-dss while accepting Ed25519, ECDSA, and FIDO-backed sk- types. Passphrases are invisible in file metadata, so that check reports NOT CHECKED and points at ssh-keygen -y.
For a world-readable id_rsa, 0o755 ~/.ssh, and an unrestricted ssh-rsa first key:
SSH dir /home/alex/.ssh: mode 0o755 WARN (expected 0o700)
private key id_rsa: mode 0o644 WARN (mode 0o644 is group/other-readable)
authorized_keys: mode 0o600 OK
authorized_keys line 1: ssh-rsa WARN (legacy algorithm; no restriction (no command=/from=/restrict))
authorized_keys line 2: ssh-ed25519 OK (restricted)
passphrase: NOT CHECKED here (use `ssh-keygen -y -f <key>` separately)
RESULT: 2 authorized_keys entries checked; warnings=3
Each WARN is a cleanup ticket: delete the entry, add a restriction, regenerate the key, or tighten the mode. Only the local account is audited; sshd_config and certificate authorities need their own review.
Further reading
- The ssh agent passes keys without exposing them
- ssh-agent remembers your decrypted keys while you work
Sources
- OpenBSD sshd manual, authorized_keys format, key options, and authentication controls, checked 2026-09-12.
- NIST IR 7966: Security of Interactive and Automated Access Management Using Secure Shell (SSH), key provisioning, rotation, and audit guidance, checked 2026-09-12.
- GitHub Docs: Connecting to GitHub with SSH, generating keys, passphrases, and key types, checked 2026-09-12.