SSH Security #
Secure Shell (SSH) is the main transport protocol Ansible uses to communicate and execute commands across all managed nodes. The security of our entire server fleet depends heavily on how secure our SSH configuration is. If the SSH configuration on target servers is weak, then all the playbook encryption and access controls we build on the control node become meaningless. Attackers can exploit outdated cryptographic protocols, brute force passwords, or use poorly protected SSH keys to take over our infrastructure.
This article deeply discusses how to apply SSH hardening automatically using Ansible. We’ll configure sshd_config to only accept modern ciphers and key exchange algorithms, manage SSH key distribution (based on the Ed25519 algorithm with strong passphrases), restrict SSH access by source IP, integrate two-factor authentication (2FA) at the PAM module level, and build audit log monitoring to detect illegal access attempts early.
When Is SSH Hardening Needed? #
SSH hardening should be done as part of the standardization baseline for every new server entering our inventory. However, the protection intensity can be adjusted based on the classification of the server environment:
ENVIRONMENTS REQUIRING STRICT HARDENING:
✓ Production servers directly accessible from the internet (public IP)
✓ Internal servers managing sensitive data (databases, internal core APIs)
✓ Jump hosts or Bastion servers (because they're the single entry point to the internal network)
✓ Staging and development servers replicating customer data
ENVIRONMENTS WITH STANDARD HARDENING (disabling password auth is enough):
✗ Local test VMs / ephemeral sandboxes that will be destroyed within hours
✗ Transient containers in fully isolated CI/CD environments
The operational cost of applying automated SSH hardening is very low because we only need to run the Ansible playbook once at the start. Conversely, its preventive impact is very high because it can fend off more than 90% of the automated scanning attacks constantly probing the standard SSH port on the internet.
SSH Architecture in Ansible #
To design effective security, we must map the SSH connection flow from the control node down to the logging level on the SIEM (Security Information and Event Management). The diagram below illustrates the SSH connection architecture:
flowchart LR
A["Ansible Control Node<br/>+ ssh-agent"] -->|"SSH key<br/>passphrase in agent"| B["Managed Node<br/>sshd"]
B -->|"Authorized key check"| C{"Key valid?"}
C -- "Yes" --> D["Login as user<br/>e.g. ansible"]
C -- "No" --> E["Reject"]
D --> F["Privilege via<br/>sudo / become"]
B -->|"Log to"| G["syslog / journal<br/>+ auditd"]
G -->|"Forward to"| H["SIEM / Loki"]
style A stroke:#4a90e2,stroke-width:2px
style B stroke:#7b68ee,stroke-width:2px
style C stroke:#f5a623,stroke-width:2px
style D stroke:#50c878,stroke-width:2px
style E stroke:#d0021b,stroke-width:2pxIn the flow above, we secure every connection stage:
- At-Rest: The private key on the control node is encrypted with a strong passphrase, and only decrypted temporarily in the
ssh-agentprocess memory. - In-Transit: The connection is verified using authorized_keys on the managed node with modern asymmetric algorithms.
- At-Run: The default SSH account must not have direct root access; privilege escalation must use the separately audited
become(sudo) mechanism. - Post-Run: Every connection and command activity is recorded in real-time by
syslogandauditd, then forwarded to the centralized log server.
Secure sshd_config Configuration #
By default, OpenSSH configuration on various Linux distributions still maintains compatibility with old systems, meaning they still allow weak encryption vulnerable to modern decryption attacks. We must replace those default configurations with a secure template via Ansible.
Recommended sshd_config Template #
We can create an sshd_config.j2 template that sets high cryptographic standards:
# roles/ssh-hardening/templates/sshd_config.j2
# SSH Server Configuration - Hardened by Ansible
# Manual modifications will be overwritten during the next run.
Port {{ ssh_custom_port | default(22) }}
Protocol 2
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
# Limit login time and number of attempts
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 5
# Forbid direct root login and disable password authentication
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
ChallengeResponseAuthentication no
KerberosAuthentication no
GSSAPIAuthentication no
UsePAM yes
# Modern Cryptography Enforcement (Only strong ciphers and KEX)
KexAlgorithms curve25519-sha256,[email protected],diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
Ciphers [email protected],[email protected]
MACs [email protected],[email protected]
# Inactive Session Timeout Settings
ClientAliveInterval 300
ClientAliveCountMax 2
# Brute Force Connection Restriction at the Daemon Level
MaxStartups 10:30:100
# High-Level Audit Log Settings
SyslogFacility AUTH
LogLevel VERBOSE
Hardening and Validation Playbook Implementation #
When reconfiguring the SSH daemon, there’s a fatal risk of losing our connection to the server if there’s a syntax error in the sshd_config file. Therefore, we must validate the configuration using the sshd -t command before restarting the service.
- name: Harden the OpenSSH daemon
hosts: all
become: true
tasks:
- name: Deploy sshd_config from the template
template:
src: templates/sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
register: sshd_config_status
- name: Validate the sshd_config syntax
command: /usr/sbin/sshd -t
changed_when: false
register: sshd_syntax_check
when: sshd_config_status.changed
- name: Restart the SSH daemon service
service:
name: sshd
state: restarted
when:
- sshd_config_status.changed
- sshd_syntax_check.rc == 0
sshd -t analyzes the entire configuration file and reports if there are unsupported options or typos. The restart task only runs if that check returns exit code 0 (success), ensuring we never get locked out of the server due to configuration errors.
Secure SSH Key Distribution #
Using SSH keys is an excellent first step, but if we don’t manage the key lifecycle properly, our system remains vulnerable. We must avoid using one shared SSH key without a passphrase for all administrators.
Generate SSH Keys with High Protection #
When creating a new key pair on our control node, make sure we use the efficient and secure Ed25519 algorithm, and add a high KDF round parameter to make offline brute force attacks harder if the private key file leaks:
# Generating a modern Ed25519-based SSH key
ssh-keygen -t ed25519 -a 100 -C "ansible-admin-production" -f ~/.ssh/ansible_ed25519
Key Distribution Playbook Using Ansible #
We can distribute the new public key to all managed nodes efficiently using the authorized_key module in Ansible. We can also enable exclusive mode to remove all other keys not explicitly registered in our playbook:
- name: Distribute the SSH public key exclusively
hosts: servers
become: true
vars:
ansible_ssh_user: deployer
tasks:
- name: Ensure the .ssh directory has safe permissions
file:
path: "/home/{{ ansible_ssh_user }}/.ssh"
state: directory
owner: "{{ ansible_ssh_user }}"
group: "{{ ansible_ssh_user }}"
mode: '0700'
- name: Register the new public key and clean up unknown keys
authorized_key:
user: "{{ ansible_ssh_user }}"
state: present
key: "{{ lookup('file', '~/.ssh/ansible_ed25519.pub') }}"
exclusive: true # Removes other keys not registered here
register: key_push_result
- name: Record the new key distribution audit log
lineinfile:
path: /var/log/ansible-key-audit.log
line: "[{{ ansible_date_time.iso8601 }}] Key distributed to {{ inventory_hostname }} for user {{ ansible_ssh_user }}"
create: true
owner: root
group: root
mode: '0600'
when: key_push_result.changed
Theexclusive: trueoption removes all other keys from theauthorized_keysfile. Make sure you’ve included all legitimate developer public keys before enabling this option, otherwise they’ll instantly lose access to the target server.
Access Restriction by IP #
Restricting SSH access at the network level is one of the most effective mitigation steps against brute force attacks. If our servers only need access from the internal VPN or a dedicated jump host, we should close SSH port access from the public internet.
Firewall Configuration via Ansible Playbook #
We can use the iptables module in Ansible to automate these firewall rules consistently:
- name: Configure IP firewall restrictions for the SSH port
hosts: production_nodes
become: true
vars:
allowed_subnets:
- 10.10.0.0/16 # Our internal VPN subnet
- 192.168.100.50 # Our Bastion host's static IP
tasks:
- name: Allow SSH connections from registered subnets
iptables:
chain: INPUT
protocol: tcp
destination_port: 22
source: "{{ item }}"
jump: ACCEPT
comment: "Allow SSH from trusted network"
loop: "{{ allowed_subnets }}"
- name: Deny SSH access from other IPs
iptables:
chain: INPUT
protocol: tcp
destination_port: 22
jump: DROP
comment: "Drop all other SSH traffic"
Implementing Fail2ban to Handle Scanner Spam #
If our servers are forced to be exposed to the public internet, we must install fail2ban to automatically block IPs detected performing repeated authentication errors:
- name: Install and configure fail2ban
hosts: public_servers
become: true
tasks:
- name: Install the fail2ban package
package:
name: fail2ban
state: present
- name: Apply the custom jail configuration for SSH
copy:
dest: /etc/fail2ban/jail.d/ssh-jail.local
content: |
[sshd]
enabled = true
port = 22
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
owner: root
group: root
mode: '0644'
register: fail2ban_config
- name: Ensure fail2ban is active and running
service:
name: fail2ban
state: restarted
enabled: true
when: fail2ban_config.changed
Two-Factor Authentication with PAM #
For production servers hosting the most sensitive data, we’re advised to add two-factor authentication (2FA) on top of SSH keys. This ensures that even if a developer’s laptop is lost or their private key is stolen, the attacker still can’t get in without a dynamic TOTP code.
Google Authenticator Integration with PAM #
We can use the Google Authenticator PAM (Pluggable Authentication Modules) module on Linux to automate this process:
- name: Configure Multi-Factor Authentication for SSH
hosts: secure_nodes
become: true
tasks:
- name: Install the Google Authenticator PAM module
package:
name: libpam-google-authenticator
state: present
- name: Enable the PAM module in the SSH configuration
lineinfile:
path: /etc/pam.d/sshd
line: "auth required pam_google_authenticator.so nullok"
state: present
- name: Adjust sshd_config to enforce 2FA
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^ChallengeResponseAuthentication"
line: "ChallengeResponseAuthentication yes"
register: sshd_pam_update
- name: Restart the SSH service if the configuration changed
service:
name: sshd
state: restarted
when: sshd_pam_update.changed
When users first log in, they must run the google-authenticator command in their shell to scan the QR code and save recovery keys. The nullok parameter on the PAM module above ensures new users can still log in to configure their 2FA the first time before the system fully requires TOTP codes on subsequent logins.
SSH Connection Auditing #
Prevention is only half of the security strategy. The other half is our ability to monitor, detect, and respond to anomalies. We must configure SSH logging to record every key exchange detail and monitor key file integrity using the audit daemon (auditd).
Integrity Monitoring Setup with Auditd #
We can deploy an auditd configuration to track illegal changes to the .ssh directory and the sshd_config file:
- name: Configure auditd logging for SSH
hosts: all
become: true
tasks:
- name: Install the auditd package
package:
name: auditd
state: present
- name: Deploy SSH file monitoring rules
copy:
dest: /etc/audit/rules.d/ssh-audit.rules
content: |
# Monitor SSH daemon configuration changes
-w /etc/ssh/sshd_config -p wa -k ssh_config_change
# Monitor server host key changes
-w /etc/ssh/ssh_host_ed25519_key -p wa -k ssh_hostkey_change
# Monitor user authorized_keys file manipulation
-w /home/deployer/.ssh/authorized_keys -p wa -k ssh_authkeys_change
owner: root
group: root
mode: '0600'
register: auditd_rules
- name: Restart the auditd daemon
service:
name: auditd
state: restarted
when: auditd_rules.changed
With these rules enabled, if an intruder successfully gets in and tries to add a backdoor SSH key to the deployer’s authorized_keys file manually bypassing Ansible, the auditd daemon records the event instantly. This log can then be read by a monitoring agent to trigger a high-severity alert on our SOC dashboard.
SSH Agent Forwarding — Risks and Mitigation #
SSH Agent Forwarding is a feature that makes it very convenient to use our local SSH key on subsequent connections initiated from the target server. However, this feature carries enormous security risk. If the target server we connected to has been compromised by an attacker, they can access our ssh-agent socket on that server to authenticate to other target servers as if they were us.
AGENT FORWARDING ATTACK SCENARIO:
Control Node (Us) --(ssh -A)--> Target Server A (Compromised!) --(Socket Access)--> Server B
An attacker on Server A with root access can hijack our SSH agent socket connection
to get into Server B without ever knowing our private key.
Mitigation Using ProxyJump (Safe Alternative) #
To avoid this risk, we must disable ForwardAgent globally in our SSH client configuration, and switch to using the ProxyJump feature. With ProxyJump, the SSH connection from the control node to the destination server is proxied through the jump host at the TCP level, without ever exposing our ssh-agent socket to that jump host.
We can configure the SSH client on our control node automatically via Ansible:
- name: Configure a secure SSH client on the control node
hosts: localhost
connection: local
tasks:
- name: Ensure the ~/.ssh/config file is securely configured
copy:
dest: "~/.ssh/config"
content: |
# Default: Disable agent forwarding globally
Host *
ForwardAgent no
StrictHostKeyChecking yes
ServerAliveInterval 120
HashKnownHosts yes
# Use ProxyJump to access the internal production network
Host 10.20.*
ProxyJump bastion.company.com
IdentityFile ~/.ssh/ansible_ed25519
owner: "{{ ansible_env.USER }}"
mode: '0600'
Decision Tree — Choosing a Hardening Strategy #
To help us determine the right hardening strategy according to our server’s network exposure posture, we can refer to the following decision tree:
flowchart TD
A["Does the server accept<br/>SSH connections?"] --> B{"Exposure"}
B -- "Internet-facing<br/>public IP" --> C["Aggressive hardening<br/>+ 2FA + fail2ban<br/>+ non-standard port"]
B -- "Internal subnet<br/>private IP" --> D["Standard hardening<br/>+ SSH key only<br/>+ auditd"]
B -- "Behind bastion<br/>jump host only" --> E["Strict hardening<br/>+ ProxyJump<br/>+ no direct SSH"]
C --> F{"Need 2FA<br/>for users?"}
F -- "Yes" --> G["Google Authenticator<br/>or DUO"]
F -- "No" --> H["SSH key only<br/>from CI/CD"]
D --> I["Distribute keys<br/>via Ansible"]
E --> I
style A stroke:#4a90e2,stroke-width:2px
style B stroke:#7b68ee,stroke-width:2px
style C stroke:#d0021b,stroke-width:2px
style D stroke:#f5a623,stroke-width:2px
style E stroke:#50c878,stroke-width:2pxBy following this decision chart, we can set the optimal security level without adding unnecessary operational friction to our non-sensitive development environments.
Summary #
- No Root Login — Disable direct root login permission (
PermitRootLogin no) insshd_configto ensure every activity can be traced back to a legitimate individual user.- No Password Authentication — Always use
PasswordAuthentication no. Password-based credentials are very vulnerable to automated brute force scanners.- Modern Algorithms — Restrict ciphers and KEX algorithms to only safe modern standards (like
curve25519-sha256and[email protected]).- Syntax Validation — Always run the
/usr/sbin/sshd -tvalidation before restarting the SSH service so we don’t get locked out due to configuration typos.- Passphrase for Keys — Use private keys with the Ed25519 algorithm protected by a strong passphrase. Avoid passphrase-less SSH keys in production environments.
- Firewall on the SSH Port — Restrict the source IPs allowed to reach our SSH port to only internal VPN IP segments or trusted bastion hosts.
- Two-Factor Authentication — Integrate PAM Google Authenticator to provide layered protection (TOTP) for human administrators accessing target servers.
- Use ProxyJump — Disable agent forwarding globally to prevent agent socket hijacking by compromised target server admins, use
ProxyJumpinstead.- Key File Auditing — Install
auditdrules to monitor unauthorized changes toauthorized_keysfiles and SSH configuration files in real-time.