SSH key hygiene starts with knowing which credential grants which access, who owns it, and how that access ends. A key comment helps people maintain an inventory, but it is not an authenticated owner record or an authorization rule. Track fingerprints alongside accountable owners, intended accounts, permitted operations, and a removal plan. Generating a modern key is only the beginning.

Scope the server-side permission

A public key in authorized_keys normally authorizes access to an account, subject to the server’s other controls. For automation, combine a narrowly privileged account with deliberately selected options. A forced command can restrict the executed operation, while from can limit source addresses. Neither magically confines the process to a working directory or makes an unsafe wrapper safe.

The restrict option disables forwarding, PTY allocation, and ~/.ssh/rc, but explicit companion options can re-enable individual facilities. It does not itself forbid shell or command execution, so an unattended job often also needs a carefully implemented forced command. A no-agent-forwarding option only blocks agent forwarding; it is not evidence that the key lacks general shell access. principals has a special meaning for trusted certificate-authority entries, not arbitrary ordinary keys.

Review the effective server configuration and alternative authorization sources. AuthorizedKeysCommand, certificate authorities, account privileges, and Match rules can change the result beyond one file. An inventory tool that sees an option name should report that observation, not issue a blanket “restricted and safe” verdict. Source restrictions also depend on the addresses actually seen by the server, including proxies and jump hosts.

Separate key types from signatures

An RSA public key line still uses the ssh-rsa key-type label even when authentication uses rsa-sha2-256 or rsa-sha2-512 signatures. OpenSSH 8.8 disabled the SHA-1 ssh-rsa signature scheme by default, not every RSA key. Inspect key size and actual negotiated signature support rather than labeling every RSA entry obsolete from its first word. DSA and weak or unsupported configurations require their own migration plan.

For new credentials, choose supported algorithms and suitable protection for the actual environment. A passphrase protects an encrypted private-key file against immediate use after copying, while hardware-backed keys can add different protections. Neither substitutes for a secure workstation, controlled agent access, and server-side scope. Automation that cannot prompt needs an explicit credential-storage and renewal design.

When it breaks

Rotation that only appends a new public key leaves the old credential authorized. Remove retired entries from every relevant authorization source, verify the replacement, and consider existing sessions separately. Ordinary raw public keys do not carry an automatic expiry. Enforce expiry through supported server options, SSH certificates, or deliberate lifecycle management; writing a date in a comment does nothing.

Permissions are another boundary, but the exact claim matters. Private keys should be owner-only, commonly mode 0600; a private directory commonly uses 0700. Public authorized_keys contents are not secret, and mode 0644 can be acceptable when ownership and write protections satisfy the server’s checks. A stricter 0600 policy is reasonable without pretending every other mode proves compromise. ACLs, parent directories, and platform rules need separate inspection.

Naively splitting authorized_keys on whitespace breaks commands containing quoted spaces. Escaped quotes and commas make a homemade parser especially easy to misread. Use OpenSSH to inspect key fingerprints, and review option semantics independently. A fingerprint match establishes which public key is present, not whether its restrictions are effective or its private half has a passphrase.

An access-inventory ledger precedes server restriction caveats, a separate RSA key-type versus SHA-2 signature comparison, and old-authorization retirement with a limited local-fixture evidence panel.
Inventory identifies a key; real authorization defines where it works. Key labels, negotiated signatures and retirement are distinct checks.

Check it quickly

Save as ssh_hygiene.py and run python3 ssh_hygiene.py with OpenSSH installed. It generates throwaway Ed25519 and 3072-bit RSA keys inside a temporary directory. Their empty passphrases are only for this disposable exercise. The script does not read or change your real SSH files, contact a host, or load an agent.

from pathlib import Path
import os
import stat
import subprocess
import tempfile


def run(*args):
    return subprocess.run(args, text=True, capture_output=True, check=True,
                          env={'PATH': os.defpath}).stdout.strip()


with tempfile.TemporaryDirectory(prefix='ssh-hygiene-') as directory:
    work = Path(directory)
    # Throwaway, passphrase-less keys for this isolated fixture only.
    ed = work / 'demo_ed25519'
    rsa = work / 'demo_rsa'
    run('ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'fixture-ed', '-f', str(ed))
    run('ssh-keygen', '-q', '-t', 'rsa', '-b', '3072', '-N', '', '-C', 'fixture-rsa', '-f', str(rsa))
    public = ed.with_suffix('.pub').read_text().strip()
    auth = work / 'authorized_keys'
    auth.write_text('restrict,command="printf \\"two words\\"" ' + public + '\n')
    auth.chmod(0o600)
    parsed = run('ssh-keygen', '-l', '-f', str(auth))
    original = run('ssh-keygen', '-l', '-f', str(ed.with_suffix('.pub')))
    if parsed.split()[1] != original.split()[1]:
        raise RuntimeError('quoted options changed the key fingerprint')
    rsa_public = rsa.with_suffix('.pub').read_text().split()[0]
    if rsa_public != 'ssh-rsa':
        raise RuntimeError('unexpected RSA public key type')
    signatures = set(run('ssh', '-Q', 'key-sig').splitlines())
    if not {'rsa-sha2-256', 'rsa-sha2-512'} <= signatures:
        raise RuntimeError('client does not advertise RSA SHA-2 signatures')
    modes = {p.name: oct(stat.S_IMODE(p.stat().st_mode)) for p in (ed, rsa, auth)}
    if any(stat.S_IMODE(p.stat().st_mode) & 0o077 for p in (ed, rsa)):
        raise RuntimeError('fixture private key permissions are too broad')
    print('quoted authorized_keys fingerprint:', parsed)
    print('RSA key label:', rsa_public, '; SHA-2 signature algorithms supported')
    print('fixture modes:', modes)
    print('PASS: native key parsing, fingerprint agreement, RSA distinction, private modes')

Expect a matching Ed25519 fingerprint despite the quoted command, an ssh-rsa public-key label alongside advertised RSA SHA-2 algorithms, owner-only private-key modes, and PASS. Fingerprints and temporary paths change on each run. The context manager attempts to remove generated files on exit; this is not secure erasure. No private key contents are printed, and this source review generated no keys or reran this fixture.

The client algorithm query reports local capability, not a server’s accepted configuration. Likewise, ssh-keygen’s fingerprint parsing is not an end-to-end test of the forced command or every option. For a real audit, map each fingerprint to its owner, verify actual server authorization and command behavior in an isolated environment, and distinguish observed facts from controls that remain untested.

Further reading

Sources

  • OpenBSD: sshd, authorized_keys format, options, and file ownership checks, checked 2026-10-15.
  • OpenSSH 8.8 release notes, RSA keys versus deprecated SHA-1 signatures, checked 2026-10-15.
  • OpenBSD: ssh-keygen, key generation and fingerprint inspection, checked 2026-10-15.
  • NIST IR 7966, SSH access inventory and lifecycle management, checked 2026-10-15.