Skip to main content
RunBook Academy

AnsibleXX · SSH Architecture and ConnectivitySSH architecture and connectivity

Host key verification is a security control

Advanced⏱ ~25 minansible-coreopenssh-client

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

Not yet marked complete on this device.

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.

Read-only / Safethe default, host_key_checking = True
$ 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)
Read-only / Safethe same run with it disabled
$ ansible-playbook -i inv.ini ping.yml -vvvv -e ansible_host_key_checking=false | grep -m1 'SSH: EXEC' | grep -o 'StrictHostKeyChecking=[a-z-]*'
StrictHostKeyChecking=no

The 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:

ValueUnknown hostChanged key
yesrefuserefuse
accept-newaccept and recordrefuse
ask (client default)prompt; without a TTY, refuserefuse
no / offaccept, do not record reliablyaccept

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.

Read-only / Safethe module contract
$ 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: full

Four 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.

Configuration changemanaging the controller known_hosts from inventory data
# 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:

Read-only / Safewhere it can be set
$ 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: true

Which means one run can treat two groups differently. Verified by execution on 2.21.3 with this inventory:

Read-only / Safethe exception, confined to one group
[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=false

Both hosts in one play, and the command lines differ:

Read-only / Safeone run, two policies
$ 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> disabled

That 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

  1. Q1. On ansible-core 2.21.3, what does host_key_checking = True add to the ssh command line Ansible builds?

  2. 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?

  3. Q3. Which statements about ansible.builtin.known_hosts are correct? Select all that apply.

  4. 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.