Skip to content

Ansible: Copy SSH Keys to All Hosts and Disable Passwords

Last updated on October 27, 2026

Updated October 2026. The 2023 version of this post had two bugs that could lock you out of your own servers. It overwrote authorized_keys with a single key, and it installed that key for root while also setting PermitRootLogin no. On current Ubuntu it did not even turn passwords off, because a file in sshd_config.d overrode the change. This version fixes all three and splits the job into two playbooks, so one mistake cannot cut you off. Tested with ansible-core 2.21.5 and ansible.posix 2.2.2 against Ubuntu 24.04 (OpenSSH 9.6) and Rocky Linux 9.8 (OpenSSH 9.9).

Whenever a batch of new VMs shows up with password logins, I do the same two things: put my key on every host, then turn passwords off, but only on hosts where the key already works. Doing that by hand on ten servers is how you end up with one box still accepting passwords. Ansible does it in two short runs.

What you need

  • A control machine with ansible-core and the ansible.posix collection (the authorized_key module lives there, not in ansible.builtin).
  • sshpass on the control machine, only for the first run, when hosts still ask for a password. On Rocky and RHEL it comes from EPEL.
  • A user with sudo rights on every host that can log in with a password. Here it is deploy.
python3 -m venv ~/ansible-venv
~/ansible-venv/bin/pip install ansible-core
~/ansible-venv/bin/ansible-galaxy collection install ansible.posix
sudo apt install sshpass

The ansible package from the distro works too. Ubuntu 24.04 ships ansible-core 2.16 with ansible.posix 1.5, and both playbooks below run on it unchanged.

1. Create a key

ssh-keygen -t ed25519 -C "ansible@control"

Ed25519 keys are short, fast and supported by every OpenSSH from the last ten years, so there is no reason to generate RSA anymore. You get ~/.ssh/id_ed25519 (keep it private) and ~/.ssh/id_ed25519.pub (this is what goes to the servers).

2. Inventory and ansible.cfg

Put everything in one directory. The inventory lists the hosts and the user Ansible logs in as:

[servers]
192.168.1.11
192.168.1.12

[servers:vars]
ansible_user=deploy
ansible_ssh_common_args=-o StrictHostKeyChecking=accept-new
ansible_python_interpreter=auto_silent

StrictHostKeyChecking=accept-new answers the classic “how do I make Ansible accept the host key automatically” question the right way. A host you have never seen is added to known_hosts on first contact, but a host whose key has changed is still refused. host_key_checking = False also gets rid of the prompt, but it turns the check off completely, man-in-the-middle included. auto_silent only hides the warning about the discovered Python interpreter.

[defaults]
inventory = inventory.ini

Check that Ansible can reach everything with the password (-k makes it ask for it):

ansible servers -m ansible.builtin.ping -k

Every host should answer "ping": "pong".

3. Playbook one: put the key on every host

---
- name: Install my SSH public key
  hosts: servers
  gather_facts: false
  tasks:
    - name: Add the key to authorized_keys of the login user
      ansible.posix.authorized_key:
        user: "{{ ansible_user }}"
        key: "{{ lookup('ansible.builtin.file', '~/.ssh/id_ed25519.pub') }}"
        state: present

authorized_key creates ~/.ssh with the right permissions if it is missing and adds the key only if it is not there yet. Keys that are already in the file, like a colleague’s, stay. The old version of this post used copy with content:, which replaces the whole file with your key and silently locks everyone else out.

ansible-playbook add-key.yml -k
PLAY RECAP *********************************************************************
192.168.1.11               : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.12               : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Now the same ping without -k. It should come back without asking for a password:

ansible servers -m ansible.builtin.ping

4. Playbook two: turn off password logins

---
- name: Turn off password logins
  hosts: servers
  become: true
  tasks:
    # A fresh ssh with the key only and no connection reuse. Ansible keeps
    # the password session from add-key.yml open for 60 s (ControlPersist),
    # so without this check a host with no key would still get hardened.
    - name: Make sure key login works before touching sshd
      ansible.builtin.command: >-
        ssh -o BatchMode=yes -o PreferredAuthentications=publickey
        -o ControlPath=none -o StrictHostKeyChecking=accept-new
        {{ ansible_user }}@{{ inventory_hostname }} true
      delegate_to: localhost
      become: false
      changed_when: false

    - name: Drop in a config snippet that wins over the distro defaults
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/00-hardening.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          PasswordAuthentication no
          KbdInteractiveAuthentication no
          PubkeyAuthentication yes
          PermitRootLogin prohibit-password
        validate: /usr/sbin/sshd -t -f %s
      notify: Restart sshd

  handlers:
    - name: Restart sshd
      ansible.builtin.service:
        name: "{{ 'ssh' if ansible_facts['os_family'] == 'Debian' else 'sshd' }}"
        state: restarted

What each part is for:

  • The first task opens a brand new SSH connection from the control machine with the key only. If the key does not work on a host, that host fails here and nothing else runs on it.
  • The snippet goes to sshd_config.d/00-hardening.conf instead of editing sshd_config. sshd uses the first value it reads for each option, the Include line sits at the top of sshd_config, and the files are read in name order, so 00- beats 50-cloud-init.conf on Ubuntu and 50-redhat.conf on Rocky.
  • validate runs sshd -t on the new file before it is put in place. A typo fails the task instead of leaving you with a dead sshd.
  • The handler restarts sshd only when the file changed. The service is called ssh on Debian and Ubuntu and sshd on RHEL, Rocky and Fedora.
  • PermitRootLogin prohibit-password blocks root passwords but keeps key logins for root, so backup jobs or other tooling that log in as root with a key keep working. Use no only if you are sure nothing does.
ansible-playbook harden-ssh.yml

This is what a run looks like when one host still has no key. The check fails on that host, its sshd stays untouched, and the other host is hardened:

TASK [Make sure key login works before touching sshd] **************************
fatal: [192.168.1.12 -> localhost]: FAILED! => {"changed": false, "cmd": ["ssh", ...
ok: [192.168.1.11 -> localhost]

PLAY RECAP *********************************************************************
192.168.1.11               : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.12               : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Fix the key on that host, run the playbook again, and it catches up. A second run on hosts that are already done reports changed=0.

5. Check the result

Ask sshd for the settings it actually uses, not the ones in the file:

ansible servers -b -m ansible.builtin.command -a "sshd -T" | grep -iE "^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin) "
permitrootlogin without-password
passwordauthentication no
kbdinteractiveauthentication no

without-password is the old name sshd still prints for prohibit-password. Then try a password login by hand. It should be refused before it even asks for the password:

ssh -o PreferredAuthentications=password deploy@192.168.1.11
deploy@192.168.1.11: Permission denied (publickey).

Gotchas

  • copy to authorized_keys wipes existing keys. Use ansible.posix.authorized_key, which only adds. Use exclusive: true only when you really want the file to contain your key and nothing else.
  • Key for root plus PermitRootLogin no is a lockout. I ran the old playbook in a lab to be sure: afterwards neither deploy nor root could log in, with a key or a password, and only the VM console got me back in. Install the key for the user you actually log in as.
  • Editing sshd_config may change nothing. Ubuntu cloud images often come with 50-cloud-init.conf that sets PasswordAuthentication yes. With that file in place, after lineinfile put PasswordAuthentication no into sshd_config, sshd -T still said yes. Always check with sshd -T.
  • ControlPersist hides a missing key. Right after add-key.yml -k, Ansible reused the open password session and gathered facts on a host that had no key at all. Only a separate ssh with ControlPath=none notices. That is why the first task in playbook two looks the way it does.
  • Keep a console open the first time. Proxmox, VMware or your cloud’s serial console. If something goes wrong, delete /etc/ssh/sshd_config.d/00-hardening.conf and restart sshd.

Takeaways

  • Two playbooks: one that adds the key with the password, one that turns passwords off and only after a key login has been proven.
  • authorized_key adds keys, copy replaces the file.
  • Put sshd settings in sshd_config.d/00-*.conf and check them with sshd -T.
  • StrictHostKeyChecking=accept-new instead of host_key_checking = False.

Links

Published inCI/CD & AutomationLinuxSecurity