AnsibleXLIX · Compliance, Validation and CertificatesCompliance, validation and certificates
Rotating a secret across three hundred hosts
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
The naive rotation:
- Change the secret at the provider.
- 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.
| Step | What runs | Estate state |
|---|---|---|
| 1. Generate | The new value is created, not yet trusted | Old value works |
| 2. Accept both | The authority accepts old and new | Both work |
| 3. Deploy | Consumers are updated, batch by batch | Both work |
| 4. Validate | Every consumer proves it uses the new value | Both work |
| 5. Retire | The authority stops accepting the old value | Only 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
$ ansible-playbook rotation-demo.yml -vTASK [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
- 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: trueThree 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.
- 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
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?
Q2. A rotation play sets no_log: true on every task touching the credential. What remains exposed? Select all that apply.
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.
Q4. Why does the rotation play call meta: flush_handlers before its validation task?
Passing score: 75%. Answers are checked in this browser.