Skip to content
Talk to our solutions team

Rotate a secret across a fleet

A credential replaced everywhere it is used, with no request failing during the change, and the old value retired only after the new one is proven.

When you finish rotation is a command rather than a maintenance window, which is what makes it something you do on a schedule instead of after an incident.

The reason most secrets are years old is that rotating them is frightening.

Without thisWith this
Rotation needs a maintenance windowIt runs while traffic flows
The old secret is deleted and something breaks an hour laterIt is retired only after the new one is verified
Nobody knows which hosts got the new valueThe run names every one
It is done by hand, so it is done onceIt runs on a schedule

Every safe rotation is the same four moves, and the order is the whole trick:

  1. Generate the new secret and store it alongside the old one.
  2. Distribute it, so every consumer holds both.
  3. Switch consumers to the new one and reload them.
  4. Retire the old one — only once step 3 is verified everywhere.

The overlap in steps 1–2 is what removes the outage. A rotation that replaces the value in one move has a window, however short, in which some consumers hold the old secret and the issuer has already forgotten it.

You needWhy
A backend that accepts two valid credentials at onceWithout it there is no overlap, and no safe rotation
A list of consumersYou cannot verify what you have not enumerated
A reload that does not drop connectionsOtherwise step 3 is an outage of its own
name: rotate-secret
list: true
vars:
secret_name: api-signing-key
tasks:
- name: generate
secret:
pattern: password
length: 48
setvar: new_value
- name: stage
vault:
operation: write
path: "{{secret_name}}-next"
value: "{{new_value}}"

Writing to <name>-next rather than over <name> is step 1’s entire point: nothing reads it yet.

Step 2 — distribute, so every consumer holds both

Section titled “Step 2 — distribute, so every consumer holds both”
tables:
hosts:
type: csv
file: ./consumers.csv
- name: distribute
map:
items_path: hosts
task: push-one
max_concurrency: 5
next:
go: verify-held
- name: push-one
ssh:
host: "{{_item.host}}:22"
username: "{{user}}"
privatekeypath: "{{keypath}}"
commands: |
set -e
install -m 0600 /dev/stdin /etc/app/secret.next <<'EOF'
{{new_value}}
EOF

Mode 0600 on the way in, not afterwards. A secret written world-readable and chmodded a second later was world-readable for a second, and that is long enough to end up in a backup.

- name: switch-one
ssh:
host: "{{_item.host}}:22"
username: "{{user}}"
privatekeypath: "{{keypath}}"
commands: |
set -e
mv /etc/app/secret.next /etc/app/secret
systemctl reload app
sleep 2
curl -fsS http://127.0.0.1:8080/ready > /dev/null

reload, not restart — a reload re-reads configuration without dropping connections. Where a service has no reload, this step is a safe restart instead, one host at a time.

Only after every consumer is verified on the new value:

- name: retire
vault:
operation: write
path: "{{secret_name}}"
value: "{{new_value}}"

Then revoke the old credential at the issuer. This is the irreversible step, and it is last for that reason: everything before it can be abandoned by leaving secret.next unused.

FlowScript
Which hosts got the new valueRecorded per nodeOnly what you logged
Failure halfwayResume from the last hostRe-run, and work out where you were
Runs on a scheduleYesNeeds a wrapper

Rotate with the flow. The list of hosts that completed is not a nicety here — it is the difference between finishing a partial rotation and starting one again from a fleet in an unknown state.

Before step 4, prove every consumer is on the new value:

Terminal window
# every host reports the new key id, and none reports the old one
kis flow -f rotate-secret.yaml -t verify-held

Then, after retiring: make one authenticated call per consumer and confirm it still succeeds. A rotation is verified by traffic, not by file contents.

ChangeHow
Certificates rather than a shared secretRenew certificates
The backend allows only one credentialRoll host by host, and accept a brief per-host failure window
On a scheduleAttach with the cron: operation
Different secret per hostOne row per host in the CSV, generate inside the loop