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-coreand theansible.posixcollection (theauthorized_keymodule lives there, not inansible.builtin). sshpasson 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 sshpassThe 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_silentStrictHostKeyChecking=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.iniCheck that Ansible can reach everything with the password (-k makes it ask for it):
ansible servers -m ansible.builtin.ping -kEvery 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: presentauthorized_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 -kPLAY 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=0Now the same ping without -k. It should come back without asking for a password:
ansible servers -m ansible.builtin.ping4. 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: restartedWhat 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.confinstead of editingsshd_config. sshd uses the first value it reads for each option, theIncludeline sits at the top ofsshd_config, and the files are read in name order, so00-beats50-cloud-init.confon Ubuntu and50-redhat.confon Rocky. validaterunssshd -ton 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
sshon Debian and Ubuntu andsshdon RHEL, Rocky and Fedora. PermitRootLogin prohibit-passwordblocks root passwords but keeps key logins for root, so backup jobs or other tooling that log in as root with a key keep working. Usenoonly if you are sure nothing does.
ansible-playbook harden-ssh.ymlThis 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=0Fix 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 nowithout-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.11deploy@192.168.1.11: Permission denied (publickey).Gotchas
copytoauthorized_keyswipes existing keys. Useansible.posix.authorized_key, which only adds. Useexclusive: trueonly when you really want the file to contain your key and nothing else.- Key for root plus
PermitRootLogin nois a lockout. I ran the old playbook in a lab to be sure: afterwards neitherdeploynor 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_configmay change nothing. Ubuntu cloud images often come with50-cloud-init.confthat setsPasswordAuthentication yes. With that file in place, afterlineinfileputPasswordAuthentication nointosshd_config,sshd -Tstill saidyes. Always check withsshd -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 separatesshwithControlPath=nonenotices. 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.confand 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_keyadds keys,copyreplaces the file.- Put sshd settings in
sshd_config.d/00-*.confand check them withsshd -T. StrictHostKeyChecking=accept-newinstead ofhost_key_checking = False.
