Editing & the workflow
sops <file> and diffs.
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.
# 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 historysops edit secrets.enc.yaml # opens decrypted; quit without saving anythingecho "exit: $?"
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.
sops edit secrets.enc.yaml # change db_password, add redis_password, save, quitgrep -c 'ENC\[' secrets.enc.yamlsed -n '1,3p' secrets.enc.yaml
3db_host: db.acme.internaldb_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 more thing sops does on save: it checks that what you wrote still parses. Save something malformed and nothing gets written.
sops edit secrets.enc.yaml # save a line with a stray tab in it, then quit
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.
git diff secrets.enc.yaml
diff --git a/secrets.enc.yaml b/secrets.enc.yamlindex 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.
# reviewing a branch: read the metadata block, not the ciphertextgit diff origin/main...feature/add-contractor -- secrets.enc.yaml
diff --git a/secrets.enc.yaml b/secrets.enc.yamlindex 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.
# any file ending .enc.yaml gets the "sopsdiffer" diff driver*.enc.yaml diff=sopsdiffer
# the driver body is local-only: every clone that wants it sets this itselfgit 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
diff --git a/secrets.enc.yaml b/secrets.enc.yamlindex 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.
# change one value in place: no editor, no plaintext file, no temp dirsops set secrets.enc.yaml '["redis_password"]' '"4Hm9-xQ2s-Lp07"'# print one value instead of the whole filesops decrypt --extract '["redis_password"]' secrets.enc.yaml
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.
# values arrive as env vars for one command, then vanish with the processsops exec-env secrets.enc.yaml 'printenv db_password'# {} is replaced with the path of a decrypted FIFOsops exec-file secrets.enc.yaml 'head -1 {}'
t8Qv-2f19-Kz3pdb_host: db.acme.internal
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.
sops filestatus secrets.enc.yamlsops filestatus /tmp/scratch-notes.yaml
{"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.
#!/usr/bin/env bash# refuse to commit a SOPS-managed file that is sitting in cleartextset -euo pipefailstatus=0files=$(git diff --cached --name-only --diff-filter=ACM \| grep -E '\.enc\.(ya?ml|json|env)$' || true)for f in $files; doif [ "$(sops filestatus "$f" | jq -r '.encrypted')" != "true" ]; thenecho "BLOCKED: $f is staged in cleartext"status=1fidoneexit $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.
# a teammate "fixed" the readable db_host line by hand, in a plain text editorsed -i 's/db.acme.internal/db-2.acme.internal/' secrets.enc.yamlsops decrypt secrets.enc.yaml > /dev/nullecho "exit: $?"
MAC mismatch. File has 9F2C1AB4...D77B, computed 41E0BB29...02A9exit: 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.
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.