CoursesSOPSPartial encryption & structure

Partial encryption & structure

Values encrypted, keys readable.

Intermediate12 min · lesson 3 of 12

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.

secrets.yaml
# Payments service, production
database:
host: db.payments.internal
port: 5432
password: S3cr3t-p@ssw0rd
terminal
export AGE_PUB=age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
sops encrypt --age "$AGE_PUB" secrets.yaml > secrets.enc.yaml
head -5 secrets.enc.yaml
output
#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.

terminal
# Rename a key with a text editor instead of with sops, then try to read the file
sed -i 's/^ password:/ password_old:/' secrets.enc.yaml
sops decrypt secrets.enc.yaml
output
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.

What lands in Git after sops encrypt
Stays cleartext
every key name
database, password, PGUSER
shape and order
nesting, list order, how many secrets exist
values your rule skipped
anything encrypted_regex did not match
Becomes ENC[...]
matched values
data + iv + tag + type, cut for the key path
comments
encrypted by default, as type:comment
nothing else
structure is never encrypted in YAML/JSON/env
Added by SOPS
sops: metadata
recipients, lastmodified, version
mac
integrity fingerprint over the values
the rule itself
encrypted_regex or unencrypted_suffix
Ciphertext length tracks plaintext length, and the recipient list names every key that can decrypt. Both are readable to anyone who can clone.

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.

terminal
sops encrypt --age "$AGE_PUB" \
--encrypted-regex '^(data|stringData)$' \
--unencrypted-suffix '_public' \
secret.yaml
output
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.

secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: payments-db
namespace: payments
type: Opaque
stringData:
PGPASSWORD: S3cr3t-p@ssw0rd
PGUSER: payments_rw
terminal
sops encrypt --age "$AGE_PUB" \
--encrypted-regex '^(data|stringData)$' \
secret.yaml > secret.enc.yaml
head -9 secret.enc.yaml
output
apiVersion: v1
kind: Secret
metadata:
name: payments-db
namespace: payments
type: Opaque
stringData:
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.

A regex that misses one field is a silent plaintext leak
--encrypted-regex is a guest list, and nothing warns you about who did not get in. Someone adds smtp_password to a file whose rule is ^(password|api_key)$ and it goes to the repository in the clear, looking perfectly normal next to its encrypted neighbours. Before you push, grep the encrypted file for a value you know is sensitive and confirm it is nowhere to be found, then check that every sensitive key shows ENC[ and that no comment is still readable. Encrypt everything unless a specific consumer genuinely needs a field readable, and when you do scope it, scope it wide: over-broad costs you a little diff readability, too narrow costs you the secret.

The Rule Lives Inside The File

secret.enc.yaml (sops block, base64 trimmed for width)
sops:
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUbExhMXlFOGFpQVZiTDhl
Wnp0ZU0yRTJ4c2RRMlNnMldjS0VjcUdqQVJzCkdjNHVPWDhBZkxRaWNSb09ocVlB
eDRNMVBjVG85azluL3dMdjdKZXFpbjgKLS0tIGlObGp4cVdTQ09aNkg0UzFJUi9U
M2FEdXVEdW1CYkowZ0xicFY5RXlLUnMKBn8EY7s2dtlMvtszccv2/odSS4HX52CU
r3BvEWQsFgPBQ2uOHKdCLpFflejAikTI24oiZvwYmdiitp0ULhNZOw==
-----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.

terminal
# Re-scope an existing file: full decrypt, then re-encrypt under the new rule
sops decrypt --in-place secrets.enc.yaml
sops encrypt --in-place --age "$AGE_PUB" \
--encrypted-regex '^(password|api_key|smtp_password)$' secrets.enc.yaml
sops filestatus secrets.enc.yaml
output
{"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.

terminal
# 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.yaml
sops decrypt secret.enc.yaml
echo "exit=$?"
output
MAC mismatch. File has 2636F1C1401E5D4EE5868228F3F645D15FBB91F27D38E749BA99FD24BAE691557C651F43DAB23A77E36FD7C5BC26E7605C2EE686AB3F51E5324F92F28D1534F8, computed 091989D37906C48154146C79B1CE296BF71F55306D9642CE5E1975B651084072F5F55BC789A40CE4BB1AFE932AC279E0D42BF07DF3F35C1C521D488EF08ED7E3
exit=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.

Never put a secret on the left of the colon
Key names are cleartext, always, with no flag to change that. A field called [email protected]: ENC[...] hides the token and broadcasts the account, the purpose and the person to everyone with repo access. The same goes for a key named stripe_live_key_rotated_after_breach. Structure is documentation, and you are publishing it. Keep sensitive data on the value side of the colon, name keys for their role rather than their story, and if a key name itself has to stay private then that file belongs in a secret store, not in Git.

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.

terminal
sops encrypt --age "$AGE_PUB" \
--encrypted-regex '^(data|stringData)$' \
secret.yaml > secret.enc.yaml
sops filestatus secret.enc.yaml
grep -c 'ENC\[' secret.enc.yaml
grep -Fq 'S3cr3t-p@ssw0rd' secret.enc.yaml && echo 'LEAK: plaintext secret present' || echo 'clean'
output
{"encrypted":true}
3
clean
Quick check
01You run sops encrypt on a YAML file with no scoping flags at all. Which parts of the committed file are readable to anyone who can clone the repo?
Correct — with no flags SOPS encrypts values and comments and leaves the document structure intact.
Incorrect — That is what binary mode does to a file SOPS cannot parse, not what happens to YAML.
Incorrect — Close, but wrong on comments: with no scoping flags they are encrypted too, as type:comment, because a comment can leak as much as the value under it.
Incorrect — Key names are never encrypted, which is exactly why you must not hide a secret in one.
02You widen encrypted_regex in .sops.yaml from ^(password)$ to ^(password|smtp_password)$ and run sops updatekeys on the existing files. What happens to smtp_password in those files?
Incorrect — updatekeys only re-wraps the data key for the current recipients; it never touches the values.
Incorrect — A value has one ciphertext shared by every recipient; encryption scope is not per-recipient.
Correct — the rule is a property of the file, so re-scoping needs a full decrypt and re-encrypt.
Incorrect — updatekeys rewrites the metadata cleanly and the MAC still verifies.
03A CI job that decrypts a Kubernetes Secret starts failing with "MAC mismatch. File has ..., computed ...". The only recent commit changed image: v1.4.2 to v1.5.0, a value the file's ^(data|stringData)$ rule leaves readable. What is happening and what do you do?
Incorrect — Rotating recipients does not cause a MAC mismatch, and updatekeys will not repair one.
Correct — the integrity check spans all values unless you deliberately narrow it, and narrowing it is a conscious trade-off.
Incorrect — That disables tamper detection for every future run, including a real attack.
Incorrect — ^(data|stringData)$ never matches image, and had the value been encrypted a text edit would have failed with a decrypt error rather than a MAC mismatch.

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.

Related