Tested on: Ubuntu 24.04 LTS and AlmaLinux 9.4, OpenSSH 9.6 / 8.7 respectively. Configuration paths and directive names below are identical across both.

Why this matters

SSH is the front door to every server on this site. Three classes of failure account for almost every real-world SSH compromise:

  1. Password authentication accepting credentials harvested elsewhere.
  2. The root account directly logged into over SSH.
  3. A key from a former employee, contractor, or compromised workstation still present in ~/.ssh/authorized_keys six months later.

This guide closes those holes and adds the minimum useful operational controls. It does not recommend changing the SSH port, fail2ban, or port-knocking as primary controls — those are noise reducers, not the security boundary. See “Gotchas” for why.

1. Create per-human accounts with sudo access

No human should log in as root and no two humans should share an account. For each operator:

 1sudo adduser alice
 2sudo usermod -aG sudo alice         # Debian/Ubuntu
 3# or:  sudo usermod -aG wheel alice # RHEL/AlmaLinux
 4sudo mkdir -p /home/alice/.ssh
 5sudo chmod 700  /home/alice/.ssh
 6# Paste their ed25519 public key into:
 7sudo -u alice tee /home/alice/.ssh/authorized_keys >/dev/null <<'EOF'
 8ssh-ed25519 AAAA...  alice@workstation 2026-05-16
 9EOF
10sudo chmod 600 /home/alice/.ssh/authorized_keys

The dated comment in the key is deliberate — when reviewing authorized_keys six months from now, you need to know when the key was authorised so you can match it against current personnel.

2. Replace password auth with keys

Edit /etc/ssh/sshd_config.d/00-hardened.conf (a drop-in file rather than editing the distro-shipped sshd_config directly — easier to manage in configuration management and easier to inspect during audits):

# Authentication
PasswordAuthentication      no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PubkeyAuthentication        yes
PermitEmptyPasswords        no
AuthenticationMethods       publickey

# No root login at all. Use sudo from a per-human account.
PermitRootLogin             no

# Restrict to a named group of operators. Add 'sftp-users' or similar
# for restricted accounts via a Match block (see section 5).
AllowGroups                 sudo

# Session hygiene
MaxAuthTries                3
MaxSessions                 4
LoginGraceTime              30
ClientAliveInterval         300
ClientAliveCountMax         2

# Protocol and crypto
Protocol                    2
HostKeyAlgorithms           ssh-ed25519,rsa-sha2-512,rsa-sha2-256
KexAlgorithms               curve25519-sha256,[email protected],[email protected]
Ciphers                     [email protected],[email protected],[email protected]
MACs                        [email protected],[email protected],[email protected]

# Reduce information leakage
DebianBanner                no   # Debian/Ubuntu only — RHEL ignores
PrintMotd                   no
PrintLastLog                yes  # Useful — shows the user when they last logged in.

# Limit forwarding unless you have a specific reason to allow it.
AllowTcpForwarding          no
AllowAgentForwarding        no
X11Forwarding               no
GatewayPorts                no
PermitTunnel                no

3. Generate / require modern keys

Ed25519 is the default in OpenSSH 8.0+ and is the right key type for new keys. RSA is still acceptable if you require 4096-bit keys for legacy compatibility, but you should not generate new RSA keys without a reason.

On each operator’s workstation:

1ssh-keygen -t ed25519 -C "alice@workstation 2026-05"

A passphrase is not technically required when keys are stored on encrypted-at-rest disks and protected by an OS-level lock screen, but defence in depth says: set one. ssh-agent removes the typing cost.

4. Apply and verify carefully

The single fastest way to lock yourself out of a remote server is to restart sshd with broken config while connected only via SSH. The safe ritual is:

 1# 1. Validate the config syntactically — refuses to apply if broken.
 2sudo sshd -t
 3
 4# 2. In a SEPARATE terminal, hold an existing session open as your
 5#    fallback before restarting the daemon.
 6
 7# 3. Reload (preferred — does not drop existing connections).
 8sudo systemctl reload sshd
 9# OR restart, if you have changed listening sockets:
10sudo systemctl restart sshd
11
12# 4. From a THIRD terminal, prove a new connection works before
13#    you close the fallback session.
14ssh -v alice@host 'whoami'

5. Restricted SFTP-only accounts (optional)

For accounts that need file transfer access but should never get a shell — backups, client uploads, restricted ops — use a chrooted SFTP setup:

# In /etc/ssh/sshd_config.d/00-hardened.conf:
Match Group sftp-only
    ChrootDirectory /srv/sftp/%u
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no
    PermitTunnel no
    AuthenticationMethods publickey

ChrootDirectory requires that the chroot root be owned by root and not writable by the user — counter-intuitive but correct. Put per-user writable data in a subdirectory inside the chroot.

6. Audit your current posture

ssh-audit is a reference scanner that maps configuration against the current recommendations:

1# From any workstation:
2pipx install ssh-audit    # or: sudo apt install ssh-audit
3ssh-audit your-server.example.com

Treat any “fail” line as an action item. “Warn” lines are usually defensible — read the explanation.

Gotchas

Changing the SSH port is theatre

Moving SSH from port 22 to a high port reduces opportunistic-scan log noise. It does not reduce risk against any attacker who runs nmap -p- once. If you change the port, do it for the log-noise reduction, not as a security control — and do not let it justify weakening any of the controls above.

fail2ban is mitigation, not authentication

Brute-forcing an ed25519 keypair is not the threat model. Brute-forcing passwords is, and the answer is PasswordAuthentication no, not 30-minute IP bans. fail2ban is fine to deploy for log hygiene; do not let its presence delay disabling password auth.

authorized_keys is the registry — keep it pruned

The single highest-leverage SSH hygiene activity is reviewing authorized_keys every quarter and removing keys for departed personnel, retired workstations, and one-off “temporary” access. A documented review cycle is also exactly the evidence ISO 27001 A.5.18 (access rights) wants to see.

Locking yourself out via AllowGroups

If you set AllowGroups sudo and your current user is not in sudo, the next sshd reload will lock you out. Verify group membership before reload:

1groups | tr ' ' '\n' | grep -E '^(sudo|wheel)$'

Shared SSH bastion accounts

A single bastion account that every operator logs into “and then sudos from” defeats the per-user audit trail. If you run an SSH bastion, give every human their own account on it. The bastion is a network choke point, not a justification for shared credentials.

Companion script

A wrapper around ssh-audit with NIS2-aligned summary output is planned at /scripts/ssh-audit-wrapper/.

What this guide deliberately does not cover

  • Bastion / jump host architecture — separate guide.
  • SSH certificate authorities (CA-signed SSH keys) — appropriate for fleets of >20 hosts; separate guide.
  • Two-factor authentication for SSH (TOTP, hardware keys, FIDO2-backed SSH keys) — separate guide.