SSH Hardening: Locking Down Your Remote Access
Why Harden SSH?
SSH (Secure Shell) is the backbone of remote server administration. It encrypts traffic and provides authenticated access — but out of the box, most SSH daemons ship with settings optimized for compatibility, not security.
A default SSH setup is vulnerable to:
- Brute-force and credential stuffing attacks — automated bots hammer port 22 around the clock
- Weak cipher suites — legacy algorithms like MD5 and arcfour can be exploited
- Root login exposure — a compromised root session means total system takeover
- Password-based auth — passwords can be guessed, leaked, or phished
- Idle session hijacking — abandoned sessions left open are an open door
Hardening SSH is one of the highest-ROI security measures you can take. It reduces your attack surface dramatically with minimal operational overhead.
Theory: How SSH Security Works
SSH operates on a client-server model. When a connection is established, several layers of security come into play:
1. Key Exchange
The client and server negotiate a shared secret using algorithms like Diffie-Hellman or ECDH. Modern best practice is to use Curve25519 (curve25519-sha256), which offers strong security and forward secrecy.
2. Host Authentication
The server presents its host key to prove its identity. Clients should verify this fingerprint on first connection and store it in ~/.ssh/known_hosts. Accepting unknown host keys blindly (StrictHostKeyChecking=no) defeats this mechanism.
3. User Authentication
The server verifies the connecting user via:
- Public key authentication (preferred) — the client proves possession of a private key without transmitting it
- Password authentication (discouraged) — susceptible to brute force
- Multi-factor authentication (optional but powerful)
4. Symmetric Encryption
Once authenticated, all traffic is encrypted using a symmetric cipher (e.g., AES-256-GCM or ChaCha20-Poly1305) negotiated during the key exchange.
5. Message Integrity
HMAC (Hash-based Message Authentication Code) ensures data has not been tampered with in transit.
Hardening Steps
Step 1 — Change the Default Port
Moving SSH off port 22 won’t stop a determined attacker, but it eliminates the majority of automated scanning noise.
Port 2222
Always open the new port in your firewall before restarting sshd.
Step 2 — Disable Root Login
Never allow direct root SSH access. Require operators to log in as a regular user and escalate with sudo.
PermitRootLogin no
Step 3 — Disable Password Authentication
Force public key authentication. This eliminates brute-force password attacks entirely.
PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
Step 4 — Restrict Allowed Users and Groups
Apply the principle of least privilege — only permit users who actually need SSH access.
AllowUsers deployer ansible-user
# or
AllowGroups sshusers
Step 5 — Use Modern Cryptographic Algorithms Only
Explicitly whitelist strong algorithms and reject legacy ones.
KexAlgorithms curve25519-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
Step 6 — Set Idle Timeout and Login Grace Period
Terminate inactive sessions and limit the time allowed to authenticate.
ClientAliveInterval 300
ClientAliveCountMax 2
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 5
Step 7 — Disable Unnecessary Features
Turn off features you don’t use — each one is a potential attack vector.
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
GatewayPorts no
PermitUserEnvironment no
Step 8 — Enable a Login Banner
Display a legal warning before authentication — required in many compliance frameworks (PCI-DSS, ISO 27001).
Banner /etc/issue.net
/etc/issue.net content:
***************************************************************************
AUTHORIZED ACCESS ONLY. All activity is monitored and logged.
Unauthorized access is prohibited and will be prosecuted.
***************************************************************************
Step 9 — Use SSH Keys with Strong Passphrases
Generate Ed25519 keys (preferred over RSA for new deployments):
ssh-keygen -t ed25519 -a 100 -C "user@hostname"
The -a 100 flag increases key derivation rounds, making passphrase brute-forcing harder.
Step 10 — Enable and Monitor Logging
Ensure SSH logs at a useful verbosity level:
LogLevel VERBOSE
SyslogFacility AUTH
Then monitor /var/log/auth.log (Debian/Ubuntu) or /var/log/secure (RHEL/CentOS) for failed attempts.
Ansible Implementation
Below is a complete, production-ready Ansible role to apply all the above hardening steps consistently across your fleet.
Directory Structure
roles/
└── ssh_hardening/
├── defaults/
│ └── main.yml
├── tasks/
│ └── main.yml
├── templates/
│ ├── sshd_config.j2
│ └── issue.net.j2
└── handlers/
└── main.yml
defaults/main.yml
---
# SSH port — change from default 22
ssh_port: 22
# Users explicitly allowed SSH access (empty = no restriction)
ssh_allowed_users: []
# Idle session timeout in seconds
ssh_client_alive_interval: 300
ssh_client_alive_count_max: 2
# Login restrictions
ssh_login_grace_time: 30
ssh_max_auth_tries: 3
ssh_max_sessions: 5
# Feature toggles
ssh_x11_forwarding: "no"
ssh_agent_forwarding: "no"
ssh_tcp_forwarding: "no"
ssh_permit_tunnel: "no"
# Cryptographic settings (modern defaults)
ssh_kex_algorithms:
- curve25519-sha256
- diffie-hellman-group16-sha512
- diffie-hellman-group18-sha512
ssh_ciphers:
- chacha20-poly1305@openssh.com
- aes256-gcm@openssh.com
- aes128-gcm@openssh.com
ssh_macs:
- hmac-sha2-512-etm@openssh.com
- hmac-sha2-256-etm@openssh.com
ssh_host_key_algorithms:
- ssh-ed25519
- rsa-sha2-512
- rsa-sha2-256
tasks/main.yml
---
- name: Ensure OpenSSH server is installed
ansible.builtin.package:
name: openssh-server
state: present
- name: Backup existing sshd_config
ansible.builtin.copy:
src: /etc/ssh/sshd_config
dest: /etc/ssh/sshd_config.bak
remote_src: true
force: false
mode: "0600"
- name: Deploy hardened sshd_config
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: "0600"
validate: "/usr/sbin/sshd -t -f %s"
notify: Restart sshd
- name: Deploy login banner
ansible.builtin.template:
src: issue.net.j2
dest: /etc/issue.net
owner: root
group: root
mode: "0644"
- name: Remove short Diffie-Hellman moduli (< 3071 bits)
ansible.builtin.shell: |
awk '$5 >= 3071' /etc/ssh/moduli > /tmp/moduli.safe
mv /tmp/moduli.safe /etc/ssh/moduli
args:
executable: /bin/bash
changed_when: false
- name: Ensure sshd is enabled and running
ansible.builtin.service:
name: sshd
state: started
enabled: true
- name: Open SSH port in firewall (firewalld)
ansible.posix.firewalld:
port: "{{ ssh_port }}/tcp"
permanent: true
state: enabled
immediate: true
when: ansible_facts.services['firewalld.service'] is defined
templates/sshd_config.j2
# Managed by Ansible — do not edit manually
# Role: ssh_hardening
# Network
Port {{ ssh_port }}
AddressFamily inet
ListenAddress 0.0.0.0
# Host keys — prefer Ed25519
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
# Cryptography
KexAlgorithms {{ ssh_kex_algorithms | join(',') }}
Ciphers {{ ssh_ciphers | join(',') }}
MACs {{ ssh_macs | join(',') }}
HostKeyAlgorithms {{ ssh_host_key_algorithms | join(',') }}
# Authentication
LoginGraceTime {{ ssh_login_grace_time }}
PermitRootLogin no
StrictModes yes
MaxAuthTries {{ ssh_max_auth_tries }}
MaxSessions {{ ssh_max_sessions }}
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
UsePAM yes
{% if ssh_allowed_users | length > 0 %}
AllowUsers {{ ssh_allowed_users | join(' ') }}
{% endif %}
# Features
X11Forwarding {{ ssh_x11_forwarding }}
AllowAgentForwarding {{ ssh_agent_forwarding }}
AllowTcpForwarding {{ ssh_tcp_forwarding }}
PermitTunnel {{ ssh_permit_tunnel }}
GatewayPorts no
PermitUserEnvironment no
# Session keepalive
ClientAliveInterval {{ ssh_client_alive_interval }}
ClientAliveCountMax {{ ssh_client_alive_count_max }}
# Logging
SyslogFacility AUTH
LogLevel VERBOSE
# Banner
Banner /etc/issue.net
# Subsystems
Subsystem sftp /usr/lib/openssh/sftp-server
templates/issue.net.j2
*******************************************************************************
* AUTHORIZED ACCESS ONLY *
* *
* This system is for authorized users only. All activity is monitored and *
* logged. Unauthorized access is strictly prohibited and will be reported. *
*******************************************************************************
handlers/main.yml
---
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted
Playbook Usage
# playbook.yml
---
- name: Harden SSH on all servers
hosts: all
become: true
roles:
- role: ssh_hardening
vars:
ssh_port: 2222
ssh_allowed_users:
- deployer
- ansible-user
ssh_max_auth_tries: 3
ssh_client_alive_interval: 180
Run it:
ansible-playbook -i inventory/hosts playbook.yml --diff
Pro tip: Use
--diffto preview every change tosshd_configbefore it is applied. Always test against a non-production host first — a broken sshd config will lock you out.
Quick Reference Checklist
| Control | Setting | Why |
|---|---|---|
| Port | Non-default (e.g. 2222) | Reduces automated scan noise |
| PermitRootLogin | no |
Prevents direct root compromise |
| PasswordAuthentication | no |
Eliminates brute force |
| PubkeyAuthentication | yes |
Strong, phish-resistant auth |
| Ciphers | AES-256-GCM, ChaCha20 | Modern, strong encryption |
| KexAlgorithms | Curve25519 | Forward secrecy |
| ClientAliveInterval | 300s | Kills idle sessions |
| MaxAuthTries | 3 | Limits guessing attempts |
| X11/TCP Forwarding | no |
Reduces attack surface |
| Banner | /etc/issue.net |
Compliance & legal notice |
Further Reading
- OpenSSH Manual Pages
- Mozilla SSH Guidelines
- CIS Benchmark for Linux
- ssh-audit — scan your SSH server for weaknesses