Skip to main content
RunBook Academy

AnsibleXLIX · Compliance, Validation and CertificatesCompliance, validation and certificates

Rotating a secret across three hundred hosts

Expert⏱ ~30 minansible-playbookansible-vault

What you'll learn

  • Sequence a rotation as generate, accept-both, deploy, validate, retire
  • Explain why an overlap period is required rather than optional
  • Apply no_log to every task that touches secret material, and know what it does not cover
  • Handle the hosts that were unreachable during the rotation window

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 naive rotation:

  1. Change the secret at the provider.
  2. Run the playbook to push the new value to 300 hosts.

Between step 1 and the moment the last host reloads, every host that has not yet been updated is authenticating with a value that no longer works. With a fleet of 300 and a play that takes eleven minutes, that is an eleven-minute authentication outage, applied to a progressively smaller fraction of the fleet, starting the instant step 1 completes.

It is also not recoverable by rolling back, because the old value does not exist any more. Part XLVIII lists provider-side secret rotation among the changes with no reverse.

The problem is ordering, and it has no atomic solution

A shared secret is held in two places: the authority that validates it, and every consumer that presents it. Changing both simultaneously across 300 machines is not something Ansible can do — not because of a limitation in Ansible, but because there is no coordinated moment across 300 independent SSH sessions.

So the sequence has to be constructed so that no moment requires simultaneity.

StepWhat runsEstate state
1. GenerateThe new value is created, not yet trustedOld value works
2. Accept bothThe authority accepts old and newBoth work
3. DeployConsumers are updated, batch by batchBoth work
4. ValidateEvery consumer proves it uses the new valueBoth work
5. RetireThe authority stops accepting the old valueOnly new works

Step 2 is the whole design. It converts a change that must be atomic into two changes that need not be, and the window between step 2 and step 5 is the overlap period.

Not every authority supports dual acceptance, and where it does not the rotation is a different, harder change:

  • Supported natively: most identity providers, API gateways with multiple active keys, database users where a second user can be created, JWT signing with a key set rather than a single key.
  • Not supported: a single shared password on a service that holds exactly one. Here the options are a brief coordinated outage, or introducing an indirection — a second credential — before rotating, so that the next rotation has the overlap this one lacked.

The second option is worth taking the first time the problem appears, because rotation is not a one-off.

no_log on everything that touches the value

Read-only / Safewhat no_log does, executed
$ ansible-playbook rotation-demo.yml -v
TASK [Task handling the secret, protected] *************************************
ok: [localhost] => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result", "changed": false}

TASK [Same task, unprotected] **************************************************
ok: [localhost] => {"ansible_facts": {"rotated2": "REPLACE_ME_NEW"}, "changed": false}

TASK [Loop with no_log] ********************************************************
ok: [localhost] => (item=(censored due to no_log)) => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result"}
ok: [localhost] => (item=(censored due to no_log)) => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result"}

Note that the loop item is censored as well as the result. That matters, because a loop over per-host credentials would otherwise print each key in the task header regardless of what the result contains.

The rotation play

Service impact possibledeploy, batched, with validation between batches
- name: Rotate the API credential on the consumers
hosts: appservers
serial: [1, 10, 50]
max_fail_percentage: 0
vars:
  api_credential: "{{ vault_api_credential_new }}"
tasks:
  - name: Write the new credential
    ansible.builtin.template:
      src: api-credentials.j2
      dest: /etc/app/credentials
      owner: root
      group: app
      mode: '0640'
    no_log: true
    notify: reload app

  - name: Apply the reload before validating
    ansible.builtin.meta: flush_handlers

  - name: The consumer authenticates with the new credential
    ansible.builtin.uri:
      url: "http://127.0.0.1:8080/healthz/upstream"
      status_code: 200
    register: upstream
    retries: 6
    delay: 5
    until: upstream is succeeded

  - name: The consumer reports the new credential fingerprint
    ansible.builtin.assert:
      that:
        - upstream.json.credential_fingerprint == expected_new_fingerprint
      fail_msg: "{{ inventory_hostname }} still presenting the previous credential"
      quiet: true

Three details.

meta: flush_handlers before validation. Handlers run at the end of the play or batch. Without the flush, the validation task tests a process that has not reloaded yet, and it will pass — against the old credential, which still works because of the overlap. The validation would be measuring nothing.

max_fail_percentage: 0 — any failure stops the rotation. A consumer that cannot authenticate with the new value is a reason to stop and understand why, not to continue and produce more of them.

A fingerprint, not the secret. The consumer reports a hash or a key ID, so the validation proves which credential is in use without any task, log or evidence file containing the credential.

Step 5 — retire, and prove it

The step that gets skipped, because after step 4 everything works.

Skipping it leaves the old value valid indefinitely. A rotation that does not retire the old credential has not rotated anything — it has added a second working credential, which is strictly worse than before, since a compromised value is now valid and nobody is watching it.

Read-only / Safeprove the old value stops working
- name: The retired credential is rejected
ansible.builtin.uri:
  url: "https://api.example.com/v1/whoami"
  headers:
    Authorization: "Bearer {{ vault_api_credential_old }}"
  status_code: [401, 403]
register: retired_check
no_log: true
delegate_to: localhost
run_once: true

- name: Record the retirement as evidence
ansible.builtin.assert:
  that:
    - retired_check.status in [401, 403]
  fail_msg: "The previous credential is still accepted - retirement did not take"
  success_msg: "Previous credential rejected with {{ retired_check.status }}"

The negative test is the only evidence that the rotation achieved its purpose. Everything before it proves the new value works, which was never in doubt.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A rotation changes the credential at the provider and then pushes the new value to 300 hosts in an eleven-minute play. What is the consequence?

  2. Q2. A rotation play sets no_log: true on every task touching the credential. What remains exposed? Select all that apply.

  3. Q3. Once every consumer has been validated as using the new credential, the rotation is complete and the old value can be left in place harmlessly.

  4. Q4. Why does the rotation play call meta: flush_handlers before its validation task?

Passing score: 75%. Answers are checked in this browser.