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 --diff to preview every change to sshd_config before 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