CoursesSOPSEditing & the workflow

Editing & the workflow

sops <file> and diffs.

Intermediate10 min · lesson 4 of 12

Nobody breaks the seal on a bank cash bag out on the sidewalk. You carry it into a back room, open it there, count the notes, seal it again, and the room gets swept before the next person walks in. sops edit is that back room. The encrypted file sitting on disk never turns into plaintext. A copy of it does, inside a temporary directory only your user account can enter, for exactly as long as your editor is running, and then that copy is deleted.

That back room is where almost all of your SOPS time goes. sops encrypt runs once, when a file is born. sops decrypt runs at deploy time on some machine with nobody watching, usually driven by Flux or Argo CD (the two common GitOps tools, which sync a Git repository into a running Kubernetes cluster). Everything in between is sops edit. Learn its failure modes and encrypted secrets stop feeling like a tax on your day. Skip them and the team quietly drifts back to keeping a decrypted copy on somebody's laptop, which is the exact outcome all this machinery exists to prevent.

The command, and the older spelling

SOPS (Secrets OPerationS) started life at Mozilla and now lives at github.com/getsops/sops, donated to the CNCF (Cloud Native Computing Foundation, the same home Kubernetes has). Version 3.9 was the release that gave it real subcommands, so the current spelling is sops edit secrets.enc.yaml. Every command and version string below is 3.13.2. The older form, sops secrets.enc.yaml with no verb at all, still works and is all over old blog posts. Write the verb anyway. Someone reading your runbook at 3am should not have to know that a bare filename means decrypt this into my editor and re-encrypt it when I quit.

sops opens whatever the EDITOR environment variable names. If EDITOR is empty it goes looking for vim, then nano, then vi, in that order, and gives up with a message if it finds none of them. The variable gets split into words the way a shell would split it, so EDITOR="code --wait" works, and that --wait is load bearing. Without it the code binary hands your file to the VS Code window that is already running and returns instantly. sops sees an editor that has exited and a file that has not changed, so it stops before you have typed a character. Same trap for subl -w, gedit -w, and anything else that throws a window at another process and returns.

terminal
# v3.9+ spelling. `sops secrets.enc.yaml` (no verb) still means the same thing.
export EDITOR="vim -n -i NONE" # -n: no swap file, -i NONE: no viminfo history
sops edit secrets.enc.yaml # opens decrypted; quit without saving anything
echo "exit: $?"
output
File has not changed, exiting.
exit: 200

Exit code 200 is not a failure in the human sense. It is sops telling you that you opened the file, changed nothing, and it therefore left the encrypted file completely untouched, down to the lastmodified timestamp. That matters the moment you wrap the command. Makefiles, shell wrappers and CI (continuous integration, the server that rebuilds and tests your repository on every push) steps treat any nonzero exit as broken, so you get a red X for the crime of changing your mind. Treat 200 as success in wrappers.

Now do a real edit. Change one value, add a second one, save, quit. There is never a moment where plaintext exists at the path Git is watching.

terminal
sops edit secrets.enc.yaml # change db_password, add redis_password, save, quit
grep -c 'ENC\[' secrets.enc.yaml
sed -n '1,3p' secrets.enc.yaml
output
3
db_host: db.acme.internal
db_password: ENC[AES256_GCM,data:Yk3s9QwT1a==,iv:9tGqZ0...,tag:Lm4kP2...,type:str]
redis_password: ENC[AES256_GCM,data:pV2m8AeR==,iv:S8Xx4T...,tag:2v9wYt...,type:str]

Three ENC[...] lines for two secrets is the right answer, not an off-by-one. The third one is the mac (message authentication code, a fingerprint computed over the file's contents) down in the metadata block at the bottom of the file, and it is encrypted too. db_host stayed readable because it was never a secret. Field names and structure in the clear, values encrypted: that is the whole SOPS bargain, and an edit does not change it.

What happens between the decrypt and the save

Four things happen, in order. First, sops unwraps the data key. Picture that key as the one house key that opens every drawer in the file: a single random 256-bit value that every secret in the file is actually encrypted with. Each recipient recorded in the file's own metadata holds a copy of that house key inside a small lockbox only they can open: your age key (age being the small modern encryption tool whose public keys start age1...), an AWS KMS (key management service) key, whatever is listed in there. Second, sops creates a temporary directory nobody else on the machine can enter, mode 0700, and writes the decrypted content inside it under the original filename, so your editor still gives you YAML highlighting. That directory is the whole protection. Third, it launches your editor and waits, doing nothing, until the editor process exits. Fourth, it re-reads the temp file, re-encrypts, writes the result over the original path, and deletes the temporary directory.

The re-encryption uses the file's own metadata, not your .sops.yaml. This is the part that catches people out. The creation_rules in .sops.yaml decide which keys a file gets at the moment it is created, and only then. Editing an existing file reuses the data key and the exact recipient list already written inside it. The encryption scope travels with the file too: if it was created with an encrypted_regex or an unencrypted_suffix, that setting lives in the metadata, so an edit keeps the same fields readable and the same fields encrypted instead of quietly rewriting the rules under you. Add a recipient to .sops.yaml and no amount of editing will apply it. sops updatekeys secrets.enc.yaml is the command that syncs a file to the current rules, and it shows you the change and asks before writing unless you pass -y.

sops rotate -i secrets.enc.yaml is the one people mix up with updatekeys, and the difference is a security difference rather than a style one. updatekeys changes who holds a lockbox with the house key in it. rotate cuts a brand new house key and re-encrypts every value under it. Drop somebody with updatekeys alone and the house key has not changed, while Git history is immutable: an old commit still carries that same data key wrapped to their public key. They clone, check out the old commit, unwrap it, and read every later version of the file. Rotation is what actually cuts them off. Changing the secret values themselves is what deals with the ones they already read.

One save, start to finish
1sops edit <file>
unwraps the data key
2decrypted temp copy
in a 0700 dir only you can enter
3$EDITOR opens
plaintext, structure intact
4save and quit
sops re-parses and validates
5re-encrypt, wipe temp
same data key, same recipients
Plaintext exists only in the temp copy, only while your editor is open. Nothing on the path to Git is ever plaintext.
The editor is the part SOPS cannot protect
sops puts the plaintext where only you can read it and deletes it when the editor exits. Anything that reaches into that buffer is outside its control. Vim swap and undo files can leave a copy behind, which is why vim -n -i NONE is worth the keystrokes. Language servers, formatters and AI autocomplete plugins read your buffer, and some of them ship it to a server. A kill -9 (the process kill nothing can catch or clean up after) or a laptop crash mid-edit leaves the temp file on disk. And a TMPDIR pointing at a synced folder (Dropbox, OneDrive, iCloud) or at a shared host turns a five-minute exposure into a permanent one. Edit secrets on a machine you trust, with an editor whose plugins you can name.

One more thing sops does on save: it checks that what you wrote still parses. Save something malformed and nothing gets written.

terminal
sops edit secrets.enc.yaml # save a line with a stray tab in it, then quit
output
Could not load tree, probably due to invalid syntax. Press a key to return to the editor, or Ctrl+C to exit.

Press any key and you land back in the editor with your broken text still in front of you, so you fix the stray tab instead of retyping a password from memory. Press Ctrl+C and sops exits without touching the encrypted file, and the edit is gone. Either way, YAML that does not parse never reaches disk. That guard earns its keep: a secrets file with a syntax error is otherwise discovered at deploy time, in production, by a pod that refuses to start.

Reading the diff

Because SOPS encrypts values and leaves field names and structure in cleartext, an ordinary git diff on an encrypted file is still worth reading. It tells you which keys exist, which ones appeared, which ones vanished, and which lines were touched. It tells you nothing about what a value became, and you should not try to squeeze more meaning out of ciphertext (the scrambled form of a secret) than that. Two lines change on every single save no matter what you did: lastmodified, and the mac. Every value you left alone stays byte for byte identical, because sops stashes the iv (initialization vector, the random starting number that makes the same password encrypt to different ciphertext every time) of each value it decrypted and reuses it when that value comes back unchanged. That is deliberate, and it is the reason these diffs are readable at all. A diff that shows only those two lines is a save that changed nothing, not a rotation.

terminal
git diff secrets.enc.yaml
output
diff --git a/secrets.enc.yaml b/secrets.enc.yaml
index 3f9a1c2..b7d40e5 100644
--- a/secrets.enc.yaml
+++ b/secrets.enc.yaml
@@ -1,5 +1,6 @@
db_host: db.acme.internal
-db_password: ENC[AES256_GCM,data:8fKz1Qd0,iv:qN1r7X...,tag:6Zb2Ku...,type:str]
+db_password: ENC[AES256_GCM,data:Yk3s9QwT1a==,iv:9tGqZ0...,tag:Lm4kP2...,type:str]
+redis_password: ENC[AES256_GCM,data:pV2m8AeR==,iv:S8Xx4T...,tag:2v9wYt...,type:str]
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ay...
@@ -12,6 +13,6 @@
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
- lastmodified: "2026-03-02T09:14:51Z"
- mac: ENC[AES256_GCM,data:Ks8Wq1...,iv:...,tag:...,type:str]
+ lastmodified: "2026-03-04T16:02:07Z"
+ mac: ENC[AES256_GCM,data:Qz1Ph7...,iv:...,tag:...,type:str]
version: 3.13.2

A reviewer's job on that diff is smaller and sharper than reading values. A pull request (the proposed change your teammates read and approve before it merges) that adds redis_password is a normal change. A pull request that adds an entry to the age or kms list inside the sops block is a change to who can read every future version of this file, and it is the one part of an encrypted secrets file anybody can read and judge without holding a key at all. Here the repository already had two recipients, a shared team key and the CI runner's key, and the branch quietly makes it three.

terminal
# reviewing a branch: read the metadata block, not the ciphertext
git diff origin/main...feature/add-contractor -- secrets.enc.yaml
output
diff --git a/secrets.enc.yaml b/secrets.enc.yaml
index b7d40e5..c02af61 100644
--- a/secrets.enc.yaml
+++ b/secrets.enc.yaml
@@ -8,6 +8,11 @@
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
+ - recipient: age1v9x2n7kq0m4t8s3f0c6h...
+ enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ ...
+ -----END AGE ENCRYPTED FILE-----
- recipient: age1c8k4d2r6y0p9w3n5m7f...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----

Notice what did not move: every value's ciphertext. Adding a recipient hands out another lockbox holding the same house key, so the new person can now read this file and every version of it that follows. Treat a new recipient as a permissions change, because that is exactly what it is, and review it the way you would review someone being added to a production access group.

Cleartext diffs, and where they leak

For the times you genuinely need to see values, Git can hire a translator. It runs each side of the comparison through a converter first and diffs the converted text. You name the translator in .gitattributes, which is committed. You say what the translator actually runs in your own local Git config, which is not committed. That split is the design working correctly: Git will not execute a command that arrived inside a repository you cloned.

.gitattributes
# any file ending .enc.yaml gets the "sopsdiffer" diff driver
*.enc.yaml diff=sopsdiffer
terminal
# the driver body is local-only: every clone that wants it sets this itself
git config diff.sopsdiffer.textconv "sops decrypt"
# if your git hands sops a temp file without the .yaml on the end, pin the format:
# git config diff.sopsdiffer.textconv "sops decrypt --input-type yaml --output-type yaml"
git diff secrets.enc.yaml
output
diff --git a/secrets.enc.yaml b/secrets.enc.yaml
index 3f9a1c2..b7d40e5 100644
--- a/secrets.enc.yaml
+++ b/secrets.enc.yaml
@@ -1,2 +1,3 @@
db_host: db.acme.internal
-db_password: 4wnQ-r0ta-t3d1
+db_password: t8Qv-2f19-Kz3p
+redis_password: 4Hm9-xQ2s-Lp07

The sops metadata block is missing from that view because sops decrypt strips it from its output, so the timestamp and mac noise disappears along with the ciphertext. Two things to hold in mind before you turn this on. Every git diff on that path now shells out to sops and hits your key backend, which for AWS KMS means a real API call and a CloudTrail (the AWS audit log of who called which API) entry per diff, and git log -p over that file will cheerfully scroll every historical version of your secrets into your terminal scrollback and into any terminal session logging you have running. And leave the cachetextconv setting off, the way it ships. Switching it on makes Git cache the converted, decrypted text in the local object store under refs/notes/textconv, which parks plaintext secrets inside .git.

Editing without opening an editor

Pipelines cannot use an editor, and some changes are too small to want one. sops set writes a single value in place. sops unset deletes one. Both take a path in SOPS's bracket syntax, where ["db"]["password"] walks into nested maps and ["hosts"][0] indexes a list, and both need exactly the same decryption rights an edit needs, because they unwrap the data key to do their work. Neither is a way to let someone add a secret they cannot read.

terminal
# change one value in place: no editor, no plaintext file, no temp dir
sops set secrets.enc.yaml '["redis_password"]' '"4Hm9-xQ2s-Lp07"'
# print one value instead of the whole file
sops decrypt --extract '["redis_password"]' secrets.enc.yaml
output
4Hm9-xQ2s-Lp07

Be clear about what --extract does: it narrows what reaches your screen, not what gets decrypted. sops still unwraps the data key and decrypts the whole tree in memory, then prints the one node you asked for. The trap in sops set is the value argument, which is parsed as JSON (JavaScript Object Notation, the format where a string carries its own double quotes). That string needs its quotes inside the shell quotes, so '"4Hm9-xQ2s-Lp07"' is a valid string and '4Hm9-xQ2s-Lp07' is not valid JSON and gets rejected before anything is written. A malformed path is rejected too, with exit code 91. Both failures are safe to script against as long as you check the exit status.

For handing secrets to a process, do not write plaintext to disk at all. sops exec-env decrypts the file into environment variables for the lifetime of one command, using the names exactly as they are spelled in the file with no uppercasing, and it needs a flat map of names to strings, so it will not work on a nested document. When a program insists on a real file, sops exec-file decrypts into a FIFO (first in, first out named pipe: a serving hatch that looks like a file to the program but holds the data in memory, never on disk) and substitutes its path wherever you write {} in the command. Add --no-fifo only if your program needs to seek backwards through the file, which a pipe cannot do.

terminal
# values arrive as env vars for one command, then vanish with the process
sops exec-env secrets.enc.yaml 'printenv db_password'
# {} is replaced with the path of a decrypted FIFO
sops exec-file secrets.enc.yaml 'head -1 {}'
output
t8Qv-2f19-Kz3p
db_host: db.acme.internal
sops decrypt -i is how repos leak plaintext
sops decrypt -i secrets.enc.yaml replaces the encrypted file with its plaintext, under the same name, inside your working tree (the files as they sit in your folder right now). One absent-minded git add -A later and the secret is in history forever, which means rotating it and telling somebody. Use sops edit for changes, sops decrypt to stdout (straight to your screen, never to a file) for reading, sops exec-env or exec-file for running things, and keep -i for the rare deliberate conversion you are watching with both eyes.

Proving the file is still encrypted

Do not eyeball it. sops filestatus answers the only question that matters before a commit, in a shape a script can read.

terminal
sops filestatus secrets.enc.yaml
sops filestatus /tmp/scratch-notes.yaml
output
{"encrypted":true}
{"encrypted":false}

Wire that into a pre-commit hook (a script Git runs before it lets a commit through), with jq (a small command line reader for JSON) pulling the field out, and a decrypted file cannot reach a commit by accident. These are the highest value fifteen lines in a SOPS repository, because the failure they prevent cannot be undone: a plaintext secret in Git history stays in Git history, and the only real fix is rotating the secret.

.git/hooks/pre-commit
#!/usr/bin/env bash
# refuse to commit a SOPS-managed file that is sitting in cleartext
set -euo pipefail
status=0
files=$(git diff --cached --name-only --diff-filter=ACM \
| grep -E '\.enc\.(ya?ml|json|env)$' || true)
for f in $files; do
if [ "$(sops filestatus "$f" | jq -r '.encrypted')" != "true" ]; then
echo "BLOCKED: $f is staged in cleartext"
status=1
fi
done
exit $status

Two honest caveats about that hook. It reads the working tree copy rather than the staged blob, so the odd case of staging an encrypted file and then decrypting it in place slips past; pipe git show ":$f" into a scratch file if you want that hole closed. And hooks live in .git, which is per clone and never pushed, so anyone can skip yours with --no-verify. Run the same check in CI, where nobody can.

The last check catches tampering rather than typos. Every value is authenticated, and the field's path is mixed into the encryption as additional data, so a ciphertext lifted out of one field will not verify if it is dropped into another. Over the top of all of it sits the mac, a wax seal across the lid: a fingerprint of the values, stored encrypted in the metadata. By default that seal covers the readable values too, not only the encrypted ones. Which means the readable parts of a SOPS file are readable, not editable.

terminal
# a teammate "fixed" the readable db_host line by hand, in a plain text editor
sed -i 's/db.acme.internal/db-2.acme.internal/' secrets.enc.yaml
sops decrypt secrets.enc.yaml > /dev/null
echo "exit: $?"
output
MAC mismatch. File has 9F2C1AB4...D77B, computed 41E0BB29...02A9
exit: 51

Recover with git checkout -- secrets.enc.yaml and redo the change through sops edit. There is an --ignore-mac flag that decrypts anyway; keep it for pulling data out of a file you have already written off, and never put it in a pipeline, because it switches off the one signal that tells you a file changed outside SOPS. And if you pipe a decrypt into anything, set -o pipefail first. Without it the shell reports only the last command's status, so sops can die on that MAC check with nothing on stdout while the next program shrugs at an empty stream: sops decrypt secrets.enc.yaml | yq '.db_password' (yq being the command line query tool for YAML) prints null and exits 0. Green pipeline, missing secret, no error anywhere.

Quick check
01You save an edit to an existing encrypted file. What does sops re-encrypt it with?
Incorrect — creation_rules apply only when a file is first created, never on a later edit.
Correct — an edit reuses the file's metadata, which is why changing .sops.yaml alone changes nothing until you run updatekeys.
Incorrect — That is what sops rotate does, deliberately, and only when you ask for it.
Incorrect — That variable supplies a key for decryption; it never rewrites who a file is encrypted to.
02You add "*.enc.yaml diff=sopsdiffer" to .gitattributes and commit it, but a teammate says git diff still shows them ENC[...] ciphertext. Why?
Correct — Git will not run a command that arrived inside a cloned repo, so the textconv body is per clone.
Incorrect — textconv is a Git feature and sops is only the command Git runs, so nothing about sops switches the driver on.
Incorrect — That setting only caches converted output, and turning it on writes decrypted text into the local object store.
Incorrect — Ignoring the file would drop it from Git entirely and has nothing to do with diff drivers.
03CI fails on a secrets file a teammate hand-edited in a text editor to fix indentation. The log reads: MAC mismatch. File has 9F2C1AB4...D77B, computed 41E0BB29...02A9, exit 51. What is the right read?
Incorrect — A key problem fails earlier, while unwrapping the data key, and says so; this check only runs after decryption already succeeded.
Incorrect — That disables the one check that tells you a file was modified outside sops, and would happily decrypt a tampered file.
Correct — readable does not mean editable, and the only safe fix is to make the change through sops.
Incorrect — updatekeys changes which keys can unwrap the data key; it has nothing to do with the integrity check over the file's values.

Try this

Run sops edit secrets.enc.yaml # opens decrypted; quit without saving anything on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: the editor is the part SOPS cannot protect. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related