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:2px

In the flow above, we secure every connection stage:

  1. At-Rest: The private key on the control node is encrypted with a strong passphrase, and only decrypted temporarily in the ssh-agent process memory.
  2. In-Transit: The connection is verified using authorized_keys on the managed node with modern asymmetric algorithms.
  3. At-Run: The default SSH account must not have direct root access; privilege escalation must use the separately audited become (sudo) mechanism.
  4. Post-Run: Every connection and command activity is recorded in real-time by syslog and auditd, 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.

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
The exclusive: true option removes all other keys from the authorized_keys file. 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:2px

By 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) in sshd_config to 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-sha256 and [email protected]).
  • Syntax Validation — Always run the /usr/sbin/sshd -t validation 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 ProxyJump instead.
  • Key File Auditing — Install auditd rules to monitor unauthorized changes to authorized_keys files and SSH configuration files in real-time.

← Previous: Secret Management Next: Common Mistake →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact