AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity
Host key verification is a security control
What you'll learn
- Explain why host_key_checking = True is the absence of a disable rather than an enforcement
- Build a managed known_hosts with ansible.builtin.known_hosts so verification stays on across re-provisioning
- Scope a host key exception to one inventory group instead of the whole controller
- Solve first contact without turning the check off for hosts that already have a key
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
The configuration part of this course already covered why
host_key_checking = False is a bad permanent answer: it disables
authentication of the server, which is the only mechanism that
distinguishes a legitimately re-provisioned host from an attacker who has
redirected the name.
This lesson is the other half. It is about keeping the check on, at
fleet scale, in an estate where machines really are rebuilt — which means
building the known_hosts that makes verification a solved operational
problem rather than a recurring obstacle.
It starts with a mechanism worth knowing before you trust anything you configure here.
True does not turn verification on
Watch what happens to the command line.
$ ansible-playbook -i inv.ini ping.yml -vvvv | grep -m1 'SSH: EXEC' | grep -o 'StrictHostKeyChecking=[a-z-]*'(no output - the option is not present at all)$ ansible-playbook -i inv.ini ping.yml -vvvv -e ansible_host_key_checking=false | grep -m1 'SSH: EXEC' | grep -o 'StrictHostKeyChecking=[a-z-]*'StrictHostKeyChecking=noThe setting has exactly one behaviour, visible in the plugin source:
if self.get_option('host_key_checking') is False:
b_args = (b"-o", b"StrictHostKeyChecking=no")
self._add_args(b_command, b_args, "ANSIBLE_HOST_KEY_CHECKING/host_key_checking disabled")
When it is False, Ansible adds an option. When it is True, Ansible adds
nothing.
The corollary is more cheerful than it sounds: because the client is in charge, the client’s own facilities are available to you, and they are better than anything Ansible offers.
What the client will actually do
StrictHostKeyChecking has more than two values, and the middle one is the
one most people need and few know about:
| Value | Unknown host | Changed key |
|---|---|---|
yes | refuse | refuse |
accept-new | accept and record | refuse |
ask (client default) | prompt; without a TTY, refuse | refuse |
no / off | accept, do not record reliably | accept |
The gap between accept-new and no is the whole of this lesson.
accept-new trusts the key the first time it sees a host and refuses if
that key ever changes afterwards. That is trust-on-first-use — genuinely
weaker than verifying the key out of band, and genuinely stronger than no,
because it detects a change. no detects nothing, ever, for any host,
including the ones whose keys you have known for years.
People reach for no because a new host blocked a run. accept-new fixes
that specific problem and keeps the protection for every host that is not
new. If you take one practical thing from this lesson, take that.
Managing known_hosts as configuration
The durable answer is to stop treating known_hosts as something that
accumulates and start treating it as something you deploy.
$ ansible-doc ansible.builtin.known_hosts hash_host Hash the hostname in the known_hosts file.
default: 'no'
type: bool
key The SSH public host key, as a string.
Required if 'state=present', optional when 'state=absent',
in which case all keys for the host are removed.
Should be of format '<hostname[,IP]> ssh-rsa <pubkey>'.
For custom SSH port, 'key' needs to specify port as well.
name The host to add or remove (must match a host specified in
key). It will be converted to lowercase so that
'ssh-keygen' can find it.
aliases: [host]
path The known_hosts file to edit.
The known_hosts file will be created if needed. The rest of
the path must exist prior to running the module.
default: ~/.ssh/known_hosts
state 'present' to add host keys.
'absent' to remove host keys.
default: present
check_mode: support: full
diff_mode: support: fullFour things in that contract deserve attention.
key is a full known_hosts line, not a public key. This trips
everyone once. It is not the contents of ssh_host_ed25519_key.pub; it is
that content with the hostname prepended, in the format the file uses. The
documentation is explicit that the value prepended must match name.
name must match what is in key. They are cross-checked. A mismatch
does not silently write a broken entry, which is the right behaviour and
also the reason your first attempt will fail.
path defaults to ~/.ssh/known_hosts — the automation account’s
file on whichever machine the task runs against. Read that again, because it
is the most common conceptual error with this module: it is a normal module
and runs on the managed node. To manage the controller’s known_hosts,
you must delegate to the controller or run a play against it.
Full check mode and diff mode support. So you can see exactly which entries a change would add or remove before it happens, which is what makes a key change reviewable.
# playbooks/controller-known-hosts.yml
# Runs against the controller. CONFIGURATION severity: it rewrites the
# automation account known_hosts, which governs every subsequent run.
- name: Maintain the fleet known_hosts on the controller
hosts: controllers
gather_facts: false
tasks:
- name: Install recorded host keys
ansible.builtin.known_hosts:
path: /etc/ssh/ssh_known_hosts
name: "{{ item.name }}"
key: "{{ item.key }}"
state: present
loop: "{{ fleet_host_keys }}"
loop_control:
label: "{{ item.name }}"
- name: Remove keys for decommissioned hosts
ansible.builtin.known_hosts:
path: /etc/ssh/ssh_known_hosts
name: "{{ item }}"
state: absent
loop: "{{ decommissioned_hosts }}"Two deliberate choices there.
/etc/ssh/ssh_known_hosts rather than a per-user file, so every account on
the controller shares one reviewed set and a new service account does not
start from an empty file. loop_control.label so the output names the host
rather than dumping the whole key on every iteration — a small thing that
makes a 400-host run readable.
Scoping the exception instead of globalising it
There is a legitimate narrow case: ephemeral infrastructure created and destroyed inside a single run, where no host key could have been known in advance. A CI container, a Molecule scenario, a test VM built ten seconds ago.
The mistake is not making an exception for those. The mistake is making it
in ansible.cfg, where it applies to production too.
host_key_checking is settable per host and per group:
$ ansible-doc -t connection ansible.builtin.ssh host_key_checking Determines if SSH should reject or not a
connection after checking host keys.
set_via:
env:
- name: ANSIBLE_HOST_KEY_CHECKING
- name: ANSIBLE_SSH_HOST_KEY_CHECKING
ini:
- key: host_key_checking
section: defaults
- key: host_key_checking
section: ssh_connection
vars:
- name: ansible_host_key_checking
- name: ansible_ssh_host_key_checking
default: trueWhich means one run can treat two groups differently. Verified by execution on 2.21.3 with this inventory:
[ephemeral]
ci1.example.com ansible_host=203.0.113.5
[prod]
web1.example.com ansible_host=192.0.2.10
# Built and destroyed within the run; no key can exist in advance.
# Reviewed 2026-08-11. Expires when the CI images carry a provisioned key.
[ephemeral:vars]
ansible_ssh_host_key_checking=falseBoth hosts in one play, and the command lines differ:
$ ansible-playbook -i inv3.ini ping.yml -vvvv | grep 'SSH: EXEC' \
| awk '{print $1, ($0 ~ /StrictHostKeyChecking=no/ ? "disabled" : "verifying")}' | sort -u<192.0.2.10> verifying
<203.0.113.5> disabledThat is the shape every exception in this course takes: named group, stated reason, recorded date, expiry condition, and a blast radius that stops at the group boundary. An exception you can point at is an exception somebody can remove. A global setting is one nobody can safely touch, because nobody knows which hosts were relying on it.
Knowledge check
Knowledge check · 4 questions
Q1. On ansible-core 2.21.3, what does host_key_checking = True add to the ssh command line Ansible builds?
Q2. A run failed because a newly built host has a key nobody has recorded. Which response keeps verification meaningful for the rest of the fleet?
Q3. Which statements about ansible.builtin.known_hosts are correct? Select all that apply.
Q4. Populating known_hosts with ssh-keyscan against an already-running fleet gives the same assurance as capturing each host key at provisioning time.
Passing score: 75%. Answers are checked in this browser.