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:
- Password authentication accepting credentials harvested elsewhere.
- The root account directly logged into over SSH.
- A key from a former employee, contractor, or compromised workstation
still present in
~/.ssh/authorized_keyssix 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.