Partial encryption & structure
Values encrypted, keys readable.
A redacted memo still tells you plenty. The letterhead, the section titles, the numbered paragraphs, the signature at the bottom: all readable. Only the sensitive words are blacked out. SOPS (Secrets OPerationS, a command-line tool started at Mozilla and now maintained by the getsops project, a sandbox project at the CNCF, the Cloud Native Computing Foundation) does that to a config file. It reads the document into a tree, walks out to the tip of every branch, and encrypts the values sitting there. Key names, nesting and ordering come back untouched. That one design choice is why teams tolerate encrypted secrets in Git at all. It is also the thing to think hardest about, because everything SOPS leaves readable is readable to every person and every bot that can clone the repository.
What Actually Changes
Start with a small secrets file, written the way a person actually writes one: two-space indent, a comment at the top, and a mix of values where only one is genuinely sensitive.
# Payments service, productiondatabase:host: db.payments.internalport: 5432password: S3cr3t-p@ssw0rd
export AGE_PUB=age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8psops encrypt --age "$AGE_PUB" secrets.yaml > secrets.enc.yamlhead -5 secrets.enc.yaml
#ENC[AES256_GCM,data:VgWnwNIozfFeJteg9CP/1Iixj0ES4uDzPumY/lA=,iv:NDD5+2nBMOQxaynTI/d/OtYkmxaPQsSVvUlpG85G8d4=,tag:fukN73RtPAlIK39ASX1pSA==,type:comment]database:host: ENC[AES256_GCM,data:IZjknnB/+ITlnaO2m78ECDy5QW4=,iv:3mTvlbLunYRwW/9iGQkOQcc9SS0ppUup0MGfzSYLUBo=,tag:Bqf4uPEeHFiXgFj65JKJrg==,type:str]port: ENC[AES256_GCM,data:GkzTSQ==,iv:M+sjEVeaEtMwOnzMjr8GbuxN0WJhPEklvGWF1WGl73I=,tag:/66WtY2ql8g4yUY8GdGdFg==,type:int]password: ENC[AES256_GCM,data:Mc7Il+on3cX0kc03Saip,iv:m4nDI/bvjD7m9sxcE7c3OWM5+O+xbBW/e0Pp5zTKX+M=,tag:uMdZ6tdm5ZmHCvYWRmGMuQ==,type:str]
Read that against the original. The comment got an envelope of its own, tagged type:comment, because a note saying "production" can give away as much as the value under it. The three key names are still sitting there in the order you wrote them, and so is the nesting. The port value carries type:int, which is SOPS remembering that 5432 was a number, so decrypting hands you back a number and not the string "5432". And the file came back with four-space indentation. SOPS does not patch your file in place. It parses, encrypts the tips of the branches, then writes the whole document out fresh, so key order and structure survive the trip while blank lines, quoting style and indent width get normalised. If four spaces upsets your linter, set stores.yaml.indent in .sops.yaml. (The subcommands used here, encrypt, decrypt, edit, filestatus and updatekeys, arrived in SOPS 3.9. The older spellings, sops -e and sops -d, still work.)
Inside One ENC Envelope
Think of each ENC[...] value as a padded shipping envelope with a printed label. The label is not secret, and it tells you how the contents were packed. Read it left to right. AES256_GCM is the cipher: AES (Advanced Encryption Standard) with a 256-bit key, running in GCM (Galois/Counter Mode), a mode that scrambles the data and also produces a short authentication tag over it, so any later edit is detectable. data is the ciphertext, written in base64 (a way of spelling raw bytes using only ordinary letters, digits and a couple of symbols, so they survive inside a text file). iv is the initialization vector, 32 random bytes used once for this one value, so encrypting the same password twice never produces the same bytes. tag is the 16-byte GCM authentication tag, the seal that proves this ciphertext has not been altered. type records what the value originally was: str, int, float, bool, or comment. One thing GCM does not hide is size. The ciphertext is exactly as long as the plaintext, so the length of data tells a reader roughly how long your password is.
There is a fifth ingredient you cannot see on the label. A hotel key card is cut for one door. Carry it down the corridor and it stops working, even though the card itself is untouched. SOPS cuts every envelope for one door too. When it encrypts a value it feeds that value's path (the chain of keys leading to it, joined with colons, so database:password:) into GCM as additional authenticated data, meaning extra context that is authenticated but never hidden. The path is glued to the ciphertext. Move the envelope to a different key and the card no longer matches the door. Prove it on a file you can throw away.
# Rename a key with a text editor instead of with sops, then try to read the filesed -i 's/^ password:/ password_old:/' secrets.enc.yamlsops decrypt secrets.enc.yaml
Error decrypting tree: Error walking tree: Could not decrypt value: cipher: message authentication failed
Nothing about the ciphertext changed there. One label did. SOPS refused anyway, because that envelope was cut for the path database:password and the path it found was database:password_old. Two things follow. First, somebody with write access to the repo cannot take an envelope they are unable to read and retarget it, dropping the staging database password into the production field to point a service at a box they control. Moving it breaks the seal. Second, you rename keys through sops edit, which decrypts into your editor, accepts the rename, and re-encrypts every affected value against its new path. Running sed over an encrypted file is how people brick their secrets on a Friday afternoon.
Choosing What Gets Encrypted
The default is to encrypt every value. That is the safe setting and often the right one. When a file mixes ordinary configuration with a couple of credentials you can narrow the scope, and four flags do it. All four match on key names, never on the values themselves. --encrypted-regex is a guest list: only keys matching the pattern (a regex, short for regular expression, a compact way of describing text to match) get encrypted. --unencrypted-regex is the mirror image, a ban list. --encrypted-suffix and --unencrypted-suffix do the same job for naming conventions like db_password_enc or motd_public. One of these is switched on before you touch anything: with no flags at all, SOPS records unencrypted_suffix: _unencrypted in the file, so a key named release_notes_unencrypted is left readable out of the box. Pick one strategy per file. SOPS refuses a combination rather than guessing which one you meant.
sops encrypt --age "$AGE_PUB" \--encrypted-regex '^(data|stringData)$' \--unencrypted-suffix '_public' \secret.yaml
Error: cannot use more than one of encrypted_suffix, unencrypted_suffix, encrypted_regex, unencrypted_regex, encrypted_comment_regex, or unencrypted_comment_regex in the same file
Two of the names in that error belong to a second pair whose naming misleads almost everybody. encrypted_comment_regex and unencrypted_comment_regex do not decide whether your comments get encrypted. They let a comment act as a sticky note on the line below it: a key whose preceding comment, or whose trailing comment on the same line, matches the pattern gets encrypted, or gets left alone. Useful when the sensitive fields share no naming convention worth matching. Both are top-level flags rather than flags the encrypt subcommand accepts, so in practice you set them in .sops.yaml as creation-rule keys.
The matching detail that trips people up is where the pattern gets applied. SOPS tests it against every component of a value's path, not only against the key directly above the value. A match on a parent key therefore covers the whole subtree beneath it. That is the trick behind the standard Kubernetes rule. Anchor your patterns with ^ and $ when you mean exact names, because Go's regular expressions match anywhere in the string: a bare key also matches monkey_name and keyboard_layout.
apiVersion: v1kind: Secretmetadata:name: payments-dbnamespace: paymentstype: OpaquestringData:PGPASSWORD: S3cr3t-p@ssw0rdPGUSER: payments_rw
sops encrypt --age "$AGE_PUB" \--encrypted-regex '^(data|stringData)$' \secret.yaml > secret.enc.yamlhead -9 secret.enc.yaml
apiVersion: v1kind: Secretmetadata:name: payments-dbnamespace: paymentstype: OpaquestringData:PGPASSWORD: ENC[AES256_GCM,data:jyhO5WYzKdszRxcvn6PE,iv:+jlNJfNKy4Ovc81bO90qdRuR90glrcJ3LezzTudrfjQ=,tag:NdQAI7NtOclJR4jP3bR3nA==,type:str]PGUSER: ENC[AES256_GCM,data:lhDOuiPg6NyJjPY=,iv:s+gM5PutzT98pewsX4vZGbQCgTo3YKy1jfjtrs73hy0=,tag:2Irn6pbNW9q6I7OaMrJhLQ==,type:str]
Keeping kind and metadata.name readable is not cosmetic. The Flux guide tells you to encrypt Kubernetes Secrets with exactly that regex, because its kustomize step has to parse the file as a Kubernetes object, to pull it into a build or apply a namePrefix or patch it, before decryption ever runs. Argo CD setups built on ksops or helm-secrets need the same thing. Encrypt the kind: line and your manifest stops being a manifest. It turns into unparseable soup that fails at build time with an error pointing nowhere near the real cause. Notice too that PGUSER got encrypted even though a username is barely a secret: the parent key matched, so everything under it was swept up. That is the price of a parent-level rule, and it is usually worth paying.
One more thing about comments, and this one is a real hole. "SOPS encrypts comments" holds for the default settings. Switch on --encrypted-regex and the rule quietly flips. SOPS works out a comment's fate from the path of whatever contains it, and a comment sitting at the top of a file is contained by nothing at all. Empty path, no match, left in the clear. A comment indented inside stringData: inherits that key's match and does get encrypted, but the # Payments service, production line from the first example would survive verbatim under this file's rule. Read the top of every partially encrypted file with your own eyes before you push it.
The Rule Lives Inside The File
sops:age:- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8penc: |-----BEGIN AGE ENCRYPTED FILE-----YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUbExhMXlFOGFpQVZiTDhlWnp0ZU0yRTJ4c2RRMlNnMldjS0VjcUdqQVJzCkdjNHVPWDhBZkxRaWNSb09ocVlBeDRNMVBjVG85azluL3dMdjdKZXFpbjgKLS0tIGlObGp4cVdTQ09aNkg0UzFJUi9UM2FEdXVEdW1CYkowZ0xicFY5RXlLUnMKBn8EY7s2dtlMvtszccv2/odSS4HX52CUr3BvEWQsFgPBQ2uOHKdCLpFflejAikTI24oiZvwYmdiitp0ULhNZOw==-----END AGE ENCRYPTED FILE-----lastmodified: "2026-07-14T08:31:52Z"mac: ENC[AES256_GCM,data:VcI4wRNX7iwa7caAzvyN2pLncpsAuHMoXKsAl84p...,iv:qekLk0G7Xj2FZiAjd+usbmVzE7688PZ8uWrj+lycNSo=,tag:AFfOdNw+j5gy73bYKmGIFw==,type:str]encrypted_regex: ^(data|stringData)$version: 3.13.2
SOPS writes this block, not you. Picture one strongbox holding the file and a row of numbered lockboxes on the wall, each holding its own copy of the strongbox key. The strongbox key is the data key, a single random AES key that every value in the file was encrypted with. The age list is the row of lockboxes: one entry per recipient, each carrying that recipient's own wrapped copy of the data key. A file protected by a cloud key shows kms entries carrying the key identifier instead, or gcp_kms, azure_kv, hc_vault, pgp, and one file can carry several at once so different holders each open it with their own credentials. lastmodified and version are bookkeeping. mac is the integrity check, which the next section pulls apart. The line that matters most is encrypted_regex. Your scoping rule is stored in the file. It is a property of the file, not a flag you re-supply, and that is where the sharp edge lives: .sops.yaml decides the rule at creation time only. Widen the regex in .sops.yaml and every existing file keeps the rule it was born with, because sops edit reads the rule out of the file it is editing.
To change the scope of a file that already exists, you decrypt it fully and encrypt it again under the new rule. sops updatekeys will not do it for you. That command re-cuts the lockbox copies for whoever .sops.yaml currently lists, which rotates who can decrypt. It never changes which values are encrypted, so it will neither widen nor narrow your scope.
# Re-scope an existing file: full decrypt, then re-encrypt under the new rulesops decrypt --in-place secrets.enc.yamlsops encrypt --in-place --age "$AGE_PUB" \--encrypted-regex '^(password|api_key|smtp_password)$' secrets.enc.yamlsops filestatus secrets.enc.yaml
{"encrypted":true}
Between those two commands the file sits on disk in the clear, so keep the window short, keep it off shared or synced storage, and do not let a git add -A run from another terminal while it is open. If a .sops.yaml creation rule already covers that path you can drop --age, since the recipients come from the rule. And sops filestatus, added in 3.9, is the cheap machine-readable answer to "is this thing actually encrypted", which is exactly the check you want in a pre-commit hook.
What The MAC Covers
The mac field (message authentication code, a fingerprint that changes if the content changes) is a wax seal stretched across the whole page rather than a lock on one blacked-out word. On encrypt, SOPS runs every value in the document through SHA-512 (a hash function, meaning it turns any amount of data into one fixed-length fingerprint) in document order, encrypts that fingerprint with the same data key, and ties it to the file's lastmodified timestamp. On decrypt it recomputes the fingerprint from what it finds and compares the two. Comments are left out of the hash. Here is the part that pays off for partial encryption: by default the fingerprint covers all values, including the ones your rule deliberately left readable.
# Everything outside stringData stayed readable in secret.enc.yaml.# Pretend an attacker with push access retargets the Secret at a namespace they can read.sed -i 's/namespace: payments/namespace: kube-system/' secret.enc.yamlsops decrypt secret.enc.yamlecho "exit=$?"
MAC mismatch. File has 2636F1C1401E5D4EE5868228F3F645D15FBB91F27D38E749BA99FD24BAE691557C651F43DAB23A77E36FD7C5BC26E7605C2EE686AB3F51E5324F92F28D1534F8, computed 091989D37906C48154146C79B1CE296BF71F55306D9642CE5E1975B651084072F5F55BC789A40CE4BB1AFE932AC279E0D42BF07DF3F35C1C521D488EF08ED7E3exit=51
That default is protection and friction in the same feature. Protection: the namespace swap above is caught at decrypt time, in CI (continuous integration, the automated pipeline that runs on every push) or in the GitOps controller, before anything reaches a cluster. Friction: any tool that legitimately rewrites the readable half breaks the file for everyone, so a bot bumping an image tag, a yq one-liner in a pipeline, or kustomize edit all produce that same MAC mismatch. Setting mac_only_encrypted: true in a .sops.yaml creation rule narrows the fingerprint to values that actually got encrypted. That makes the cleartext half freely editable by other tools and, to be blunt, no longer protected: with that setting the namespace swap sails straight through. A --mac-only-encrypted flag exists too, but it is a top-level flag rather than one the encrypt subcommand takes, so it has to sit before the verb, as in sops --mac-only-encrypted --encrypt secret.yaml. For a repository, the creation rule is the saner choice. Pick it deliberately, per file, and never reach for sops decrypt --ignore-mac to turn a red pipeline green. That flag switches tamper detection off. A MAC mismatch on a file nobody meant to touch is an integrity incident, and the right next move is git log -p on that path.
What A Reader Of Your Repo Still Learns
Encryption bought you the values. It bought you nothing else. Anyone who can clone the repository reads every field name, the full shape of your configuration, how many credentials each environment holds, and roughly how long each one is, since AES-GCM ciphertext is the same length as its plaintext and the base64 in data grows with it. A twelve-character password and a 4096-bit private key are obvious at a glance, and a diff that lengthens a value tells a watcher you rotated it and to what size. The metadata gives up more: the recipient list names every key that can decrypt, and a KMS (Key Management Service, a cloud provider's own key vault) entry carries an ARN (Amazon Resource Name, the full identifier of a cloud resource) with your account number, region and key alias in the clear. None of that is a reason to avoid SOPS. It is the reason to keep encrypted secrets in a repository whose read access you still control.
One habit catches most partial-encryption mistakes before they turn into commits. After any change to a secrets file, check three things: that SOPS still calls the file encrypted, that the number of lines carrying ENC[ is the number you expect, and that a value you know is sensitive cannot be found anywhere in it. For the Kubernetes file above the expected count is three, being PGPASSWORD, PGUSER, and the mac line down in the metadata. If that number drops, a sensitive field has slipped outside your rule.
sops encrypt --age "$AGE_PUB" \--encrypted-regex '^(data|stringData)$' \secret.yaml > secret.enc.yamlsops filestatus secret.enc.yamlgrep -c 'ENC\[' secret.enc.yamlgrep -Fq 'S3cr3t-p@ssw0rd' secret.enc.yaml && echo 'LEAK: plaintext secret present' || echo 'clean'
{"encrypted":true}3clean
Try this
Run sops encrypt --age "$AGE_PUB" secrets.yaml > secrets.enc.yaml 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: a regex that misses one field is a silent plaintext leak. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.