iac.htora.dev · security templates

Home/Templates/Lock SSH down to keys only

Lock SSH down to keys only

Turn off password logins and root logins, and check the config before restarting so you cannot lock yourself out.

Ansible

Why bother

Anything with SSH open to the internet gets password guesses within minutes of coming online, continuously, forever. Keys remove the whole category. The risk is in the change itself: a typo in the config plus a restart leaves you locked out of a machine you now cannot reach. These templates check the file is valid and check you have a working key before they touch anything.

How you know it worked

From another machine, try to log in with a password. It should be refused before it even asks.

Set it up

Pick your platform, then open the level you want. Each level is complete on its own. Read the comments in the files as you go. Anything with a real consequence is explained on the line where it happens.

Ansible. A drop-in sshd config, validated before reload

What you need first

  • An inventory, and an account you can become root with.
  • A working SSH key for that account on every host. If one is missing, the templates stop before changing anything.
  • A way back in that is not SSH, the first time you run this on a host you cannot walk up to.

What it creates

  • /etc/ssh/sshd_config.d/00-hardening.conf
  • An Include line in the main config, if the image does not already have one

The code

Quick startDraft1 file, 107 lines
One playbook. Key check, drop-in file, safe reload.
outcomes/ssh-hardening/linux/t0
playbook.yml
---
# ssh-hardening / linux / t0 "Quick start"
#
# Turns off password logins and root logins on every host in your inventory.
#
# Run:
#   ansible-playbook -i inventory.ini playbook.yml --check    # see what would change
#   ansible-playbook -i inventory.ini playbook.yml            # do it
#
# Verify:
#   ssh -o PubkeyAuthentication=no you@host
#   Expect: Permission denied (publickey). It should refuse before asking for a password.
#
# Two safety steps are built in, because the usual way this goes wrong is being
# locked out of a machine you can no longer reach:
#   1. It refuses to run if the account you are connecting as has no SSH key.
#   2. It checks the config file is valid before reloading the service.
#
# If your keys come from somewhere other than authorized_keys, such as SSH
# certificates or an AuthorizedKeysCommand, skip the first check:
#   ansible-playbook -i inventory.ini playbook.yml -e skip_key_check=true

- name: Lock SSH down to keys only
  hosts: all
  become: true
  gather_facts: true

  vars:
    # The only thing you might want to change. Raise it if you use a bastion
    # that retries, lower it to be stricter.
    max_auth_tries: 4

  pre_tasks:
    # become: false and a ~ path, so this looks in the home directory of the
    # account Ansible logs in as. Facts are gathered as root, so a path built
    # from them would check root's keys instead.
    - name: Look for an authorized key on the account we connected as
      ansible.builtin.stat:
        path: "~/.ssh/authorized_keys"
      become: false
      register: authorized_keys
      when: not (skip_key_check | default(false) | bool)

    - name: Stop if there is no key, because turning off passwords would lock us out
      ansible.builtin.assert:
        that:
          - authorized_keys.stat.exists
          - authorized_keys.stat.size > 0
        fail_msg: >-
          {{ inventory_hostname }} has no authorized_keys for
          {{ ansible_user | default('the account Ansible connects as') }}.
          Install a key first. Turning off password logins now would lock you out.
        success_msg: "Key found, safe to continue."
      when: not (skip_key_check | default(false) | bool)

  tasks:
    - name: Make sure the drop-in directory exists
      ansible.builtin.file:
        path: /etc/ssh/sshd_config.d
        state: directory
        owner: root
        group: root
        mode: "0755"

    # Some older images have no Include line, so a drop-in file would be read by
    # nobody and the playbook would report success while changing nothing.
    - name: Make sure the main config reads the drop-in directory
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        line: "Include /etc/ssh/sshd_config.d/*.conf"
        insertbefore: BOF
        state: present
        validate: /usr/sbin/sshd -t -f %s
      notify: Reload sshd

    # sshd keeps the FIRST value it reads for each setting, and reads drop-ins
    # in name order. A 00- prefix makes these win over files the distribution
    # ships, such as 50-cloud-init.conf, which often turns passwords back on.
    - name: Write the hardening settings
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/00-hardening.conf
        owner: root
        group: root
        mode: "0600"
        validate: /usr/sbin/sshd -t -f %s
        content: |
          # Managed by Ansible. Local edits are overwritten on the next run.
          PasswordAuthentication no
          KbdInteractiveAuthentication no
          PermitEmptyPasswords no
          PermitRootLogin no
          PubkeyAuthentication yes
          MaxAuthTries {{ max_auth_tries }}
          LoginGraceTime 30
          X11Forwarding no
          AllowAgentForwarding no
          ClientAliveInterval 300
          ClientAliveCountMax 2
      notify: Reload sshd

  handlers:
    # A reload keeps existing sessions alive, where a restart ends them. So if
    # the new config is wrong you are still connected and can put it back.
    - name: Reload sshd
      ansible.builtin.service:
        name: "{{ 'sshd' if ansible_facts['os_family'] == 'RedHat' else 'ssh' }}"
        state: reloaded
StandardDraft6 files, 194 lines
A role. Adds cipher and key exchange lists, AllowGroups, forwarding switches, and a read-back of sshd -T that fails if passwords are still accepted.
outcomes/ssh-hardening/linux/t1
roles/ssh_hardening/defaults/main.yml
---
# Every setting the role understands, with the value used when you say nothing.
# Override in group_vars or host_vars. Do not edit this file.

# Refuse to run when the connecting account has no authorized_keys file. Turn
# this off for hosts that use SSH certificates or an AuthorizedKeysCommand, or
# that you can reach another way, such as a console.
ssh_require_key_present: true

# Authentication
ssh_password_authentication: false
ssh_permit_root_login: "no"          # no | prohibit-password | yes
ssh_max_auth_tries: 4
ssh_login_grace_time: 30

# Who may log in at all. An empty list means no restriction, which is the safe
# default, because a wrong group name here locks out everyone.
ssh_allow_groups: []
ssh_allow_users: []

# Forwarding. Agent forwarding lets whoever controls the host use your key
# elsewhere, so it is off unless you say otherwise.
ssh_x11_forwarding: false
ssh_agent_forwarding: false
ssh_tcp_forwarding: true

# Idle sessions
ssh_client_alive_interval: 300
ssh_client_alive_count_max: 2

# Cryptography. These lists drop the older algorithms. Check your oldest client
# before narrowing them further.
ssh_kex_algorithms:
  - curve25519-sha256
  - curve25519-sha256@libssh.org
  - diffie-hellman-group16-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

# Where the drop-in goes. sshd keeps the first value it reads for each setting
# and reads drop-ins in name order, so 00 wins over anything the distribution
# ships. A 99- prefix would lose to 50-cloud-init.conf on most cloud images.
ssh_dropin_path: /etc/ssh/sshd_config.d/00-hardening.conf
roles/ssh_hardening/handlers/main.yml
---
# A reload leaves existing sessions connected, where a restart ends them. So a
# bad config does not cut you off while you still have a way to fix it.
- name: Reload sshd
  ansible.builtin.service:
    name: "{{ ssh_service_name }}"
    state: reloaded
roles/ssh_hardening/meta/main.yml
---
galaxy_info:
  role_name: ssh_hardening
  description: Key-only SSH with a validated config and a safe reload.
  license: MIT
  min_ansible_version: "2.14"
  platforms:
    - name: Ubuntu
      versions: [jammy, noble]
    - name: Debian
      versions: [bookworm]
    - name: EL
      versions: ["8", "9"]
dependencies: []
roles/ssh_hardening/tasks/main.yml
---
- name: Work out what the SSH service is called here
  ansible.builtin.set_fact:
    ssh_service_name: "{{ 'sshd' if ansible_facts['os_family'] == 'RedHat' else 'ssh' }}"

- name: Check the connecting account has a key
  when: ssh_require_key_present | bool
  block:
    # become: false and a ~ path, so this checks the account Ansible logs in
    # as. Facts are gathered as root, so a path built from them would not.
    - name: Look for authorized_keys
      ansible.builtin.stat:
        path: "~/.ssh/authorized_keys"
      become: false
      register: ssh_authorized_keys

    - name: Stop if there is none
      ansible.builtin.assert:
        that:
          - ssh_authorized_keys.stat.exists
          - ssh_authorized_keys.stat.size > 0
        fail_msg: >-
          {{ inventory_hostname }} has no authorized_keys for
          {{ ansible_user | default('the account Ansible connects as') }}.
          Install a key first, or set ssh_require_key_present to false if keys
          come from certificates or you have console access.

- name: Make sure the drop-in directory exists
  ansible.builtin.file:
    path: "{{ ssh_dropin_path | dirname }}"
    state: directory
    owner: root
    group: root
    mode: "0755"

# Without this line the drop-in file is read by nobody, and the run reports
# success while changing nothing at all.
- name: Make sure the main config reads the drop-in directory
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    line: "Include {{ ssh_dropin_path | dirname }}/*.conf"
    insertbefore: BOF
    state: present
    validate: /usr/sbin/sshd -t -f %s
  notify: Reload sshd

- name: Write the hardening settings
  ansible.builtin.template:
    src: hardening.conf.j2
    dest: "{{ ssh_dropin_path }}"
    owner: root
    group: root
    mode: "0600"
    validate: /usr/sbin/sshd -t -f %s
  notify: Reload sshd

- name: Make sure sshd is enabled and running
  ansible.builtin.service:
    name: "{{ ssh_service_name }}"
    enabled: true
    state: started

# Proves the result by reading it back. Runs every time, costs nothing, and
# fails loudly if some later change quietly turns passwords back on.
- name: Read back the settings the server will actually use
  ansible.builtin.command: /usr/sbin/sshd -T
  changed_when: false
  register: ssh_effective

- name: Confirm password logins are off
  ansible.builtin.assert:
    that:
      - "'passwordauthentication no' in ssh_effective.stdout | lower"
    fail_msg: >-
      sshd still accepts passwords. Another file is setting it first. Check
      {{ ssh_dropin_path | dirname }} for a file that sorts before
      {{ ssh_dropin_path | basename }}.
    success_msg: "Key-only logins confirmed."
roles/ssh_hardening/templates/hardening.conf.j2
# Managed by Ansible, role ssh_hardening. Local edits are overwritten.
PasswordAuthentication {{ 'yes' if ssh_password_authentication else 'no' }}
KbdInteractiveAuthentication {{ 'yes' if ssh_password_authentication else 'no' }}
PermitEmptyPasswords no
PermitRootLogin {{ ssh_permit_root_login }}
PubkeyAuthentication yes

MaxAuthTries {{ ssh_max_auth_tries }}
LoginGraceTime {{ ssh_login_grace_time }}
{% if ssh_allow_groups %}
AllowGroups {{ ssh_allow_groups | join(' ') }}
{% endif %}
{% if ssh_allow_users %}
AllowUsers {{ ssh_allow_users | join(' ') }}
{% endif %}

X11Forwarding {{ 'yes' if ssh_x11_forwarding else 'no' }}
AllowAgentForwarding {{ 'yes' if ssh_agent_forwarding else 'no' }}
AllowTcpForwarding {{ 'yes' if ssh_tcp_forwarding else 'no' }}

ClientAliveInterval {{ ssh_client_alive_interval }}
ClientAliveCountMax {{ ssh_client_alive_count_max }}

KexAlgorithms {{ ssh_kex_algorithms | join(',') }}
Ciphers {{ ssh_ciphers | join(',') }}
MACs {{ ssh_macs | join(',') }}
site.yml
---
# ssh-hardening / linux / t1 "Standard"
#
# Same result as the quick start, as a role you can reuse and override per group.
#
# Run:
#   ansible-playbook -i inventory.ini site.yml --check --diff
#   ansible-playbook -i inventory.ini site.yml
#
# Override per group in your inventory, and leave the role itself untouched:
#   group_vars/bastion.yml
#     ssh_allow_groups: [ssh-users, jump-admins]
#     ssh_max_auth_tries: 6

- name: Harden SSH
  hosts: all
  become: true
  gather_facts: true
  roles:
    - role: ssh_hardening
      tags: [ssh, hardening]
HardenedPlanned
Not written yet.
What catches people

sshd keeps the first value it reads for each setting, and it reads drop-in files in name order. Many cloud images ship 50-cloud-init.conf with passwords switched on, so a file named 99-hardening.conf loses to it and the run still reports success. These templates use 00- so they are read first.

Older images have no Include line in sshd_config, so a drop-in file is read by nobody at all. Both templates add the line, and the Standard role reads back sshd -T afterwards and fails if passwords still work.

How to undo it

Delete the drop-in file and reload sshd. The templates reload the service, so an existing session stays open even when the new config is wrong. That open session is usually the difference between a quick fix and a trip to the data centre.

What it costs

None.

Registry 0.6.0. Built 2026-09-22.

Made by Habibullah Tora. Code under the MIT licence, writing under CC BY 4.0.