Changing an age recipient in SOPS is an access migration, not a one-command key swap. A safe rotation keeps the existing identity usable while the replacement is added, proves the replacement across every encrypted file, removes the retired recipient, rotates each file’s data key after removal, and additionally replaces the credentials stored inside the files when the old identity may have exposed them.
Three different objects are involved. An age identity is the private key held by a person or automation system. Its public recipient appears in SOPS metadata. A separate data encryption key protects the document values. Finally, database passwords, API tokens, and other payload credentials are the secrets the document contains. Confusing those layers can leave a former key holder authorized or can expose newly changed credentials to an identity you meant to retire.
The reproduced workflow below used checksum-verified SOPS 3.13.3 and age 1.3.1 on Debian 13. Two encrypted YAML files passed four states: old identity only, old-plus-new overlap, new identity only, and exact ciphertext rollback. The final cutover preserved both plaintext hashes, left one new recipient in each file, rejected the old identity twice, and removed only the guarded disposable directory.
SOPS provides two related operations. sops updatekeys synchronizes the recipients recorded in an encrypted file with .sops.yaml; adding a recipient can rewrap the existing data key without changing every encrypted value. sops rotate, by contrast, generates a new data key and re-encrypts the document values. Current SOPS key-management documentation recommends updatekeys for recipient changes and documents an additional data-key rotation after removing a compromised key.
| Change | What it modifies | When it is enough |
|---|---|---|
add a recipient with updatekeys |
who can unwrap the current file data key | planned overlap while the old identity remains trusted |
remove a recipient with updatekeys |
who is listed in current SOPS metadata | synchronization step after all files and consumers pass; not a complete offboarding endpoint |
run rotate --in-place |
document data key and encrypted value material | after recipient removal so the retired holder does not retain the prior data key boundary |
| replace payload credentials | database passwords, API tokens, certificates, or other actual secrets | when the old identity may already have read those values |
Removing a recipient with updatekeys stops current ciphertext from wrapping its data key for that identity, but it is not a complete revocation boundary by itself. A former holder may have retained the prior data key, ciphertext, clone, commit, artifact, or plaintext. After any recipient removal, rotate each file’s data key. After suspected exposure, complete that recipient and data-key cutover first; then rotate every payload credential the old holder could have learned.
The overlap principle resembles the pattern in measured SSH host-identity cutovers, but the trust direction differs. SSH host keys prove a server to clients. An age identity lets its holder unwrap SOPS data keys. Treat the two pages as separate runbooks.
Before editing .sops.yaml, identify the files and consumers in scope. Include encrypted YAML, JSON, dotenv, and binary files selected by every creation rule—not just files matching the name used in a tutorial. Locate CI jobs, Flux or other GitOps controllers, developer workstations, break-glass storage, and scheduled automation that currently provide an age private identity.
Write a secret-free acceptance contract:
.sops.yaml exists outside the working tree;Hash receipts do not prove that a baseline was trustworthy. They prove only that the same plaintext crossed the access migration. If repository files need broader tamper detection, establish a separate known-good file-integrity baseline and keep its approval process outside the host it measures.
The commands in this article form one sequential shell session. They use a temporary path, synthetic values, and private identity files with mode 0600. Do not paste production credentials into the fixture. The release pages for SOPS 3.13.3 and age 1.3.1 are the authority for the tested artifacts; recheck current releases and checksums when reproducing later.
set -euo pipefail
umask 077
LAB=$(mktemp -d -p /tmp sops-age-rotation.XXXXXXXX)
BIN="$LAB/bin"
REPO="$LAB/repo"
KEYS="$LAB/keys"
BACKUP="$LAB/ciphertext-before"
install -d -m 0700 "$BIN" "$REPO" "$KEYS" "$BACKUP"
curl -fsSLo "$BIN/sops"
https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.amd64
curl -fsSLo "$BIN/age.tar.gz"
https://github.com/FiloSottile/age/releases/download/v1.3.1/age-v1.3.1-linux-amd64.tar.gz
printf '%s %sn'
e5bec3346a873ae91d871550f3e698c1aad962aff462a080e40f25fde17fef6b "$BIN/sops"
bdc69c09cbdd6cf8b1f333d372a1f58247b3a33146406333e30c0f26e8f51377 "$BIN/age.tar.gz"
| sha256sum -c -
chmod 0700 "$BIN/sops"
tar -xzf "$BIN/age.tar.gz" -C "$BIN"
export PATH="$BIN:$BIN/age:$PATH"
sops --version | head -n 1
age-keygen --version
Checksums in a copied article age quickly. If the release version changes, do not keep the version and replace only a hash until verification turns green. Select the intended platform asset from the official release, obtain its published digest through the same release record, and review the current key-management behavior before changing production files.
The fixture covers two files so repository-wide selection is real rather than assumed from one happy path. Both payloads are synthetic. Private age identities stay under $KEYS, outside $REPO; only public recipients enter .sops.yaml. The official SOPS age identity guide documents SOPS_AGE_KEY_FILE, the default lookup paths, and multiple recipients.
age-keygen -o "$KEYS/old.agekey"
age-keygen -o "$KEYS/new.agekey"
chmod 0600 "$KEYS"/*.agekey
OLD_RECIPIENT=$(age-keygen -y "$KEYS/old.agekey")
NEW_RECIPIENT=$(age-keygen -y "$KEYS/new.agekey")
cat >"$REPO/app.enc.yaml" <<'YAML'
database:
host: db.internal.example
username: lab_app
password: synthetic-app-password-v1
service:
token: synthetic-service-token-v1
YAML
cat >"$REPO/worker.enc.yaml" <<'YAML'
queue:
endpoint: queue.internal.example
username: lab_worker
password: synthetic-worker-password-v1
YAML
cat >"$REPO/.sops.yaml" <<EOF
creation_rules:
- path_regex: '.*.enc.yaml$'
age: >-
$OLD_RECIPIENT
EOF
for file in "$REPO"/*.enc.yaml; do
SOPS_AGE_KEY_FILE="$KEYS/old.agekey"
sops --config "$REPO/.sops.yaml" encrypt --in-place "$file"
done
cp -p "$REPO/.sops.yaml" "$REPO"/*.enc.yaml "$BACKUP/"
Keep the recovery copy where an accidental Git operation cannot modify it. A repository backup protects encrypted files but should not silently collect private age identities. When the Git platform itself is part of the risk, use Forgejo restore rehearsal guidance to separate repository data from application metadata and prove both planes before a crisis.
An overlap is useful only when the baseline is known. Capture a deterministic list, decrypt every file with the current identity, save a plaintext hash without saving plaintext, and prove the replacement identity is not already authorized. The negative test matters: a new key that unexpectedly works may already be present through another key group, environment variable, default identity file, or external identity command.
find "$REPO" -maxdepth 1 -type f -name '*.enc.yaml' -print0
| sort -z >"$LAB/encrypted-files.list0"
: >"$LAB/plaintext-baseline.sha256"
while IFS= read -r -d '' file; do
base=$(basename "$file")
SOPS_AGE_KEY_FILE="$KEYS/old.agekey" sops decrypt "$file"
| sha256sum | sed "s# -# $base#" >>"$LAB/plaintext-baseline.sha256"
if SOPS_AGE_KEY_FILE="$KEYS/new.agekey" sops decrypt "$file" >/dev/null 2>&1; then
printf 'unexpected baseline access: %sn' "$base" >&2
exit 1
fi
done <"$LAB/encrypted-files.list0"
Use the same explicit identity source throughout the test. If the shell also has SOPS_AGE_KEY, SOPS_AGE_KEY_CMD, SSH identities, or a default SOPS age key file, a failure test can pass for the wrong reason. In CI, inventory those sources without printing private material.
Do not delete the old recipient first during a planned migration. Add both public recipients to the applicable creation rule, use one currently authorized identity to synchronize every file, then test every file independently with old and new private identities.
cat >"$REPO/.sops.yaml" <<EOF
creation_rules:
- path_regex: '.*.enc.yaml$'
age: >-
$OLD_RECIPIENT,
$NEW_RECIPIENT
EOF
while IFS= read -r -d '' file; do
SOPS_AGE_KEY_FILE="$KEYS/old.agekey"
sops --config "$REPO/.sops.yaml" updatekeys --yes "$file"
SOPS_AGE_KEY_FILE="$KEYS/old.agekey" sops decrypt "$file" >/dev/null
SOPS_AGE_KEY_FILE="$KEYS/new.agekey" sops decrypt "$file" >/dev/null
done <"$LAB/encrypted-files.list0"
Commit boundaries should reflect operational decisions. A planned team migration can use one reviewed overlap commit, deploy the new identity to every authorized consumer, collect positive receipts, and only then prepare a retirement commit. Avoid leaving an indefinite dual-recipient state merely because both keys work.
CI runners deserve their own acceptance line. Self-hosted GitHub Actions runner guidance matters here because a runner can execute repository code and read deployment secrets. Load the new identity through the platform’s protected secret mechanism and test an authorized decryption job before removing the old value. Never print the identity, decrypted file, or environment dump into logs.
Once every required consumer passes with the new identity, remove the old public recipient from .sops.yaml. For any retirement, follow the documented order: synchronize the recipient removal first, then rotate the data key. If the key was compromised or potentially exposed, only afterward change actual credentials inside the documents. Rotating the data key while the retired recipient is still present can wrap the replacement data key for the identity you meant to exclude.
cat >"$REPO/.sops.yaml" <<EOF
creation_rules:
- path_regex: '.*.enc.yaml$'
age: >-
$NEW_RECIPIENT
EOF
while IFS= read -r -d '' file; do
SOPS_AGE_KEY_FILE="$KEYS/new.agekey"
sops --config "$REPO/.sops.yaml" updatekeys --yes "$file"
SOPS_AGE_KEY_FILE="$KEYS/new.agekey" sops rotate --in-place "$file"
done <"$LAB/encrypted-files.list0"
: >"$LAB/plaintext-final.sha256"
while IFS= read -r -d '' file; do
base=$(basename "$file")
SOPS_AGE_KEY_FILE="$KEYS/new.agekey" sops decrypt "$file"
| sha256sum | sed "s# -# $base#" >>"$LAB/plaintext-final.sha256"
if SOPS_AGE_KEY_FILE="$KEYS/old.agekey" sops decrypt "$file" >/dev/null 2>&1; then
printf 'retired identity still works: %sn' "$base" >&2
exit 1
fi
recipients=$(grep -Ec '^[[:space:]]+recipient:' "$file" || true)
[[ "$recipients" -eq 1 ]]
grep -Fq -- "$NEW_RECIPIENT" "$file"
! grep -Fq -- "$OLD_RECIPIENT" "$file"
done <"$LAB/encrypted-files.list0"
find "$REPO" -maxdepth 1 -type f -name '*.enc.yaml' -print0
| sort -z >"$LAB/encrypted-files-final.list0"
cmp "$LAB/encrypted-files.list0" "$LAB/encrypted-files-final.list0"
diff -u "$LAB/plaintext-baseline.sha256" "$LAB/plaintext-final.sha256"
The reproduced run returned this secret-free receipt. Recipient fingerprints are hashes of public recipients, not private key material.
sops_version=sops 3.13.3 (latest)
age_version=v1.3.1
sops_checksum=pass
age_checksum=pass
encrypted_files=2
baseline_old_decrypt=2/2
baseline_new_rejected=2/2
overlap_old_decrypt=2/2
overlap_new_decrypt=2/2
final_new_decrypt=2/2
final_old_rejected=2/2
final_new_only_metadata=2/2
plaintext_hashes_unchanged=2/2
rollback_old_decrypt=2/2
rollback_new_rejected=2/2
verdict=pass
cleanup=pass
One green deployment is not repository coverage. Fail the change if the post-cutover file list differs from the declared inventory, a file lacks the expected recipient, plaintext hashes diverge unexpectedly, or any retired identity still decrypts. When applications use shared upstream credentials, consider adopting per-consumer credential boundaries so a future leak can revoke one workload without forcing every client to change simultaneously.
A ciphertext rollback is appropriate for an operator mistake only while the old identity remains trusted. Restoring pre-change SOPS files reauthorizes every recipient contained in that backup. Do not use this rollback after suspected compromise. In compromise response, recover with a surviving trusted identity or offline break-glass path, complete the removal, and rotate payload credentials.
cp -p "$BACKUP/.sops.yaml" "$REPO/.sops.yaml"
cp -p "$BACKUP/app.enc.yaml" "$REPO/app.enc.yaml"
cp -p "$BACKUP/worker.enc.yaml" "$REPO/worker.enc.yaml"
for file in "$REPO"/*.enc.yaml; do
SOPS_AGE_KEY_FILE="$KEYS/old.agekey" sops decrypt "$file" >/dev/null
if SOPS_AGE_KEY_FILE="$KEYS/new.agekey" sops decrypt "$file" >/dev/null 2>&1; then
printf 'rollback recipient mismatch: %sn' "$file" >&2
exit 1
fi
done
[[ "$LAB" == /tmp/sops-age-rotation.* ]]
rm -rf -- "$LAB"
[[ ! -e "$LAB" ]]
Production recovery should preserve an evidence bundle before cleanup: version and checksum receipts, file inventory, old/new public recipient fingerprints, success and rejection counts, plaintext hash comparison, commit IDs, consumer acknowledgments, and the operator who authorized retirement. Keep private identities and decrypted values out of that bundle.
Finish the retirement review against the complete declared file inventory. Every file should decrypt with the new identity, reject the retired identity, list only the intended public recipients, and preserve its approved plaintext hash. Confirm that downstream consumers have loaded the new identity, rollback remains limited to a still-trusted key, and cleanup removed the guarded lab path.
For production repositories, rehearse the repository change like any other stateful migration. Terraform-to-OpenTofu readiness guidance illustrates the same discipline—back up current state, prove compatibility before the destructive boundary, and keep a tested return path—without implying that infrastructure state and SOPS ciphertext are interchangeable.
sops updatekeys and sops rotate?sops updatekeys synchronizes the file’s recipients with .sops.yaml; sops rotate generates a new data encryption key and re-encrypts document values. Adding a trusted recipient usually starts with updatekeys. After removing any recipient, synchronize removal first and then rotate the data key; compromise additionally requires rotating payload credentials the old holder may have read.
It should not decrypt the new file version once every matching SOPS file has been synchronized and verified. The identity may still decrypt older commits, backups, artifacts, or copies that retain its recipient metadata, so removal cannot revoke historical access retroactively.
Rotate payload credentials when the retired identity may have been exposed or its holder may have read them. Finish recipient removal and data-key rotation first; otherwise newly issued credentials could be encrypted while the compromised recipient still has access.
Freeze a null-delimited inventory before the change, process that exact list, compare it after the change, inspect each file’s public recipient metadata, decrypt each file with the new identity, and require the old identity to fail. A successful test on one representative file is not repository coverage.
For a planned migration, separate reviewed commits make the decision boundary visible: first add and deploy the new identity, then remove the old one after consumers pass. During active compromise, minimize exposure time and follow an incident-controlled sequence instead of waiting for a routine deployment cadence.
Use it in a controlled environment to add a verified replacement before deleting anything. If the only identity is already lost, SOPS cannot recover the file data key from ciphertext alone; restore a protected identity backup or recover the plaintext from another authorized system.
No. Updating a controller or CI secret changes which private identity that consumer holds. The encrypted files must separately contain the corresponding public recipient, and both the repository update and downstream deployment must be verified before the old identity is removed.
A SOPS age rotation is complete when the team can answer five questions from evidence: Which files were in scope? Which consumers accepted the replacement? Can the replacement decrypt every current file? Does the retired identity fail everywhere it should? Were actual payload credentials rotated when historical access made that necessary?
Record those answers without recording the secrets themselves. That receipt turns a recipient edit into an auditable access transition—and gives the next operator a precise starting point instead of another untested key file.