Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

SOPS: encrypting your secrets in Git

By updated

Improve this page

SOPS encrypts the values of a YAML, JSON, ENV or INI file while leaving its keys readable, which lets you version secrets in Git and still read a diff. This lesson is for the DevOps engineer who has to keep secrets in a repository, typically in GitOps, without leaving them in clear. It covers encryption with age, injection without a file, teamwork and CI/CD integration.

Everything is measured on SOPS 3.13.3 and age 1.3.2, in an Alpine container. Three results change the way you use it. By default, all values and even comments are encrypted, but key names, types and the length of values remain visible. Removing a member with updatekeys does not change the data key: with an old version of the file taken from Git, they can still decrypt the new values, until sops rotate. And post-quantum age keys already work.

What you will learn

  • Install SOPS and age while checking signature and checksum
  • Encrypt and decrypt YAML, JSON, ENV and binary files with age
  • Measure what an encrypted file reveals: keys, types, lengths, recipients
  • Inject secrets with exec-env and exec-file, without a file in clear
  • Share secrets as a team: several keys, updatekeys, rotate and Shamir threshold
  • Meet requirement SOCLE-SEC-STO-2 (in French): no secret in clear in the project files

What is SOPS?

SOPS (Secrets OPerationS) is an editor of encrypted files created by Mozilla, now maintained by the community under getsops/sops. It handles the YAML, JSON, ENV, INI and binary formats, and delegates the protection of its encryption key to a backend: age, PGP, AWS KMS, GCP KMS, Azure Key Vault or a Vault/OpenBao server. The version measured here is 3.13.3, released on 23 July 2026.

Why SOPS rather than something else?

The choice comes down to a trade-off: are you willing to run a centralised service for your secrets, or do you prefer them to live in the Git repository with the code? SOPS fills the "no infrastructure" slot while keeping multiple recipients and diffs where the structure stays readable.

CriterionSOPS + ageAnsible VaultHashiCorp Vault / OpenBao
StorageGit filesGit filesCentralised service
Readable diffsYes, keys in clearNo for a whole file; encrypt_string encrypts one variableNot applicable
InfrastructureNoneNoneServer, high availability
Several recipientsNative, one key per personSeparate passwords per vault IDAccess policies
Audit of readsNo (except the KMS log)NoYes

The last row is the limit to remember: SOPS protects the file at rest, but does not say who decrypted it. A vault such as OpenBao traces every read; with SOPS, only a KMS key brings a log (CloudTrail at AWS).

How does SOPS encrypt files?

SOPS uses envelope encryption. A data key specific to the file encrypts each value with AES-256-GCM; this data key is then encrypted separately for each recipient (an age key, a KMS key), and the envelopes are stored in a sops: block with a MAC that detects any change. The diagram follows this two-level scheme, with the data key in red at the centre.

SOPS envelope encryption: a single data key per file encrypts the values with AES-256-GCM (ENC[AES256_GCM,...]); the same data key is encrypted for each recipient, Alice's age envelope, Bob's age envelope, AWS KMS envelope, all stored in the sops: block with the MAC.AES-256-GCMencrypted forencrypted forencrypted forFile values(keys in clear)Data key(one per file)Encrypted valuesENC[AES256_GCM,...]age envelopeAliceage envelopeBobAWS KMSenvelopesops: blockenvelopes + MAC
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Adding a recipient therefore barely increases the file size: you add an envelope, not a copy of the values. And removing a recipient changes nothing in the encrypted values as long as the data key is not renewed, a point measured further down.

Installation

SOPS is a single static binary, with no service to start. Installation boils down to fetching this binary, checking its checksum and putting it in the PATH. You then need to install the chosen backend, age in this lesson, because SOPS does not protect its data key on its own.

Choosing a backend

SOPS does not create keys: it encrypts its data key with a backend, to be chosen before the first command. The official documentation recommends age over PGP when possible; cloud KMS services bring IAM-based control and an access log.

BackendTypical useAdvantage
ageTeams, personal projectsSimple, offline, no service
AWS KMSAWS projectsIAM control, CloudTrail log
GCP KMSGCP projectsIAM control
Azure Key VaultAzure projectsEntra ID control
Vault / OpenBao (Transit)Vault already in placeEncryption delegated to the vault

Installing SOPS

The project publishes one binary per platform, a checksums file sops-v3.13.3.checksums.txt and the signature of that file, produced by cosign with the OIDC identity of the release workflow. Checking the signature proves the origin of the checksums; checking the checksum proves the binary's integrity.

Terminal window
# Binary, checksums file and signature of the checksums file
curl -sLO https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.amd64
curl -sLO https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.checksums.txt
curl -sLO https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.checksums.sigstore.json
# 1. The checksums file really comes from the getsops repository (cosign 3.1.1)
cosign verify-blob sops-v3.13.3.checksums.txt \
--bundle sops-v3.13.3.checksums.sigstore.json \
--certificate-identity-regexp=https://github.com/getsops \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com
# Verified OK
# 2. The binary matches its checksum
sha256sum --check --ignore-missing sops-v3.13.3.checksums.txt
# sops-v3.13.3.linux.amd64: OK
sudo install -m 0755 sops-v3.13.3.linux.amd64 /usr/local/bin/sops
sops --version --disable-version-check
# sops 3.13.3

Without options, sops --version queries GitHub to report a newer version, and SOPS 3.13.3 prints a warning: this default behaviour is deprecated. The --disable-version-check option, or the SOPS_DISABLE_VERSION_CHECK=true variable, avoids this network call, which is useful in CI.

Installing age

The Linux archive of age 1.3.2 contains age, age-keygen, age-inspect and three compatibility plugins. It comes without a checksums file: the SHA-256 checksum of each artifact is shown on the GitHub release page, and that is what the command compares.

Terminal window
curl -sLO https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz
# Checksum published on the GitHub release v1.3.2
echo "cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 age-v1.3.2-linux-amd64.tar.gz" \
| sha256sum --check
# age-v1.3.2-linux-amd64.tar.gz: OK
tar xzf age-v1.3.2-linux-amd64.tar.gz
sudo install -m 0755 age/age age/age-keygen /usr/local/bin/
age --version
# v1.3.2

Post-quantum age: supported

age 1.3.0 introduced hybrid post-quantum keys (ML-KEM-768 and X25519), which start with age1pq1. SOPS has supported them since version 3.12.0 ("Age: support hybrid post-quantum identities"). Measured with SOPS 3.13.3: age-keygen -pq produces a public key of 1959 characters, and both encryption and decryption work.

The price is size: the same YAML file weighs 5792 bytes when encrypted for a post-quantum key, against 1548 bytes for a classic age key. For a repository that must stay readable, keep these keys for secrets whose confidentiality must last for years.

Encrypting with age

The cycle fits in three commands: generate a key pair, encrypt with the public key, decrypt with the private key. The age1... public key can be shared safely and committed in .sops.yaml; the private key stays on the workstation. No service is involved, the workflow works offline.

Creating an age key

age-keygen writes the private key to the given file, with 0600 permissions, and prints the public key. SOPS looks for its identities in ~/.config/sops/age/keys.txt on Linux, hence the path chosen.

  1. Create the keys directory

    Terminal window
    mkdir -p ~/.config/sops/age
  2. Generate a key

    Terminal window
    age-keygen -o ~/.config/sops/age/keys.txt
    # Public key: age1rxzcv6lwdvh0k9xyr442v70j4jpkmldchf4xpx5rqeq0elx44y3sa6thtl
  3. Note the public key

    The public key (age1...) is used to encrypt; age-keygen -y keys.txt prints it again at any time. The private key stays in keys.txt.

Encrypting a file

sops encrypt writes the result to standard output: without a redirection, the encrypted file is printed and the original stays in clear on disk. Delete the original once the encrypted file is committed, and add it to .gitignore.

Terminal window
cat > secrets.yaml <<'EOF'
# Base de production
database:
host: db.example.com
port: 5432
username: admin
password: super-secret-123 # à tourner tous les 90 jours
replicas:
- db1.example.com
api:
token: sk-1234567890abcdef
enabled: true
EOF
sops encrypt --age age1rxzcv6lwdvh0k9xyr442v70j4jpkmldchf4xpx5rqeq0elx44y3sa6thtl \
secrets.yaml > secrets.enc.yaml

The measured result, shortened: all values are encrypted, host and port included, and even the comments. The sops: block holds the age envelope, the recipient, the date and the MAC.

#ENC[AES256_GCM,data:uyZcSv8N3RJXMbKWMyL+qpFRYA==,iv:...,tag:...,type:comment]
database:
host: ENC[AES256_GCM,data:UlSv5JPrwd6nBmILQ+U=,iv:...,tag:...,type:str]
port: ENC[AES256_GCM,data:fNHrgw==,iv:...,tag:...,type:int]
username: ENC[AES256_GCM,data:lI/85LQ=,iv:...,tag:...,type:str]
password: ENC[AES256_GCM,data:+31UjqiqVmVp3iNSk06O1w==,iv:...,type:str] #ENC[...,type:comment]
replicas:
- ENC[AES256_GCM,data:BdeCuyKGCsL6FeSOmnoD,iv:...,tag:...,type:str]
api:
token: ENC[AES256_GCM,data:JdaMriEP4rBaiSAWZFNWoZvMyw==,iv:...,tag:...,type:str]
enabled: ENC[AES256_GCM,data:9uAflw==,iv:...,tag:...,type:bool]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
recipient: age1rxzcv6lwdvh0k9xyr442v70j4jpkmldchf4xpx5rqeq0elx44y3sa6thtl
lastmodified: "2026-10-01T13:31:56Z"
mac: ENC[AES256_GCM,data:...,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3

What stays in clear in the file

A SOPS file is not an opaque box, and that is deliberate: the structure stays readable for review. But it means the file reveals several pieces of information to anyone who reads the repository. The measurement finds six, to take into account before publishing a repository.

What stays visibleMeasured example
Key names and hierarchydatabase.password, api.token
Type of each valuetype:str, type:int, type:bool, type:comment
Number of items in a listreplicas contains one entry
Length of each value1 character gives data:dg==, 40 characters give 40 encrypted bytes
Recipientsage public keys, ARNs of KMS keys
Metadatalastmodified date, SOPS version

Length is the most underestimated leak: AES-GCM does not pad values, and an 8-character password can be told apart from a 40-character API key. Key names matter too: stripe_live_key tells an attacker what to look for. Choose neutral names for public repositories.

Decrypting a file

No key option is needed to decrypt: the list of recipients is in the sops: block, and SOPS tries the identities it finds. It looks for them in particular, as its error messages show, in SOPS_AGE_SSH_PRIVATE_KEY_FILE, ~/.ssh/id_ed25519, SOPS_AGE_KEY, SOPS_AGE_KEY_FILE, SOPS_AGE_KEY_CMD and ~/.config/sops/age/keys.txt.

Terminal window
# With the key in the default location
sops decrypt secrets.enc.yaml
# With a key file elsewhere
SOPS_AGE_KEY_FILE=~/keys/ma-cle.txt sops decrypt secrets.enc.yaml
# With the key content in a variable (CI case)
SOPS_AGE_KEY="$(grep AGE-SECRET-KEY ~/keys/ma-cle.txt)" sops decrypt secrets.enc.yaml

With no key at all, the command exits with code 128 and Failed to get the data key required to decrypt the SOPS file, plus the list of locations tried. With a key that is not a recipient, the message becomes no identity matched any of the recipients.

Editing an encrypted file

sops edit is the everyday command: the decrypted content only exists in a temporary file, deleted when the editor closes, and SOPS re-encrypts on save. The editor is chosen with SOPS_EDITOR, since SOPS 3.10, or failing that with EDITOR.

Terminal window
# Opens the decrypted file in the editor, re-encrypts on save
SOPS_EDITOR=vim sops edit secrets.enc.yaml

Other formats

SOPS detects the format from the file extension. The ENV format encrypts each KEY=value line and stores its metadata in sops_* lines. Any unknown extension, .pem included, is treated as binary: the whole content becomes a single data value in a JSON document.

Terminal window
cat > secrets.env <<'EOF'
DB_HOST=db.example.com
DB_PASSWORD=super-secret-123
API_TOKEN=sk-1234567890abcdef
EOF
sops encrypt --age age1... secrets.env > secrets.enc.env
# DB_PASSWORD=ENC[AES256_GCM,data:neYlNO1YUTm9+4rwjQoHZw==,iv:...,type:str]
# sops_age__list_0__map_recipient=age1...
# sops_version=3.13.3

In-place encryption, standard input and output file

The -i option (--in-place) replaces the file with its encrypted version, which assumes a copy in Git before running it. Since SOPS 3.10, SOPS also reads standard input, provided you tell it which file it is processing with --filename-override, otherwise it refuses.

Terminal window
# Encrypt in place
sops encrypt -i --age age1... secrets.yaml
# Standard input: --filename-override sets the format and the .sops.yaml rule
echo '{"secret": "from-stdin"}' | sops encrypt --filename-override secret.json --age age1...
# Without the option: Error: must specify --filename-override when reading from stdin
# Decrypt to a file
sops decrypt secrets.enc.yaml --output secrets.yaml

The file written by --output gets the system's default permissions: measured with a umask of 0022, it is created as 0644, hence readable by every user on the machine. Prefer the exec-env and exec-file commands, which avoid this file in clear.

Selective encryption

By default, SOPS encrypts all values. Selective encryption keeps non-sensitive ones (hostname, port, public URL) readable to ease review. The downside is real: each value left in clear is one more piece of information for an attacker, and a regular expression mistake leaves a secret in clear without warning.

encrypted_regex: encrypt only some keys

The --encrypted-regex option only encrypts the keys whose name matches the regular expression. Measured: host, port, username and replicas stay in clear, and so does the comment attached to password, since only the matching value is encrypted.

Terminal window
sops encrypt --age age1... \
--encrypted-regex '^(password|token)$' \
secrets.yaml > secrets.enc.yaml
# Base de production
database:
host: db.example.com
port: 5432
username: admin
password: ENC[AES256_GCM,data:FzSPf3Eu2kjxJpJSjUnNkg==,iv:...,type:str] # à tourner tous les 90 jours
replicas:
- db1.example.com
api:
token: ENC[AES256_GCM,data:qmzPg5zuWmBv6SITwoVemljqiA==,iv:...,type:str]
enabled: true

unencrypted_regex and suffixes

The opposite option, --unencrypted-regex, encrypts everything except the designated keys. Filtering by suffix also exists, and SOPS applies one by default: any key ending in _unencrypted stays in clear, as the unencrypted_suffix line of the sops: block reminds you.

Terminal window
# Encrypt everything except host, username and port
sops encrypt --age age1... --unencrypted-regex '^(host|username|port)$' secrets.yaml
# Encrypt only the keys ending in _secret
sops encrypt --age age1... --encrypted-suffix '_secret' config.yaml
# db_secret: ENC[AES256_GCM,...]
# host: x

Configuring in .sops.yaml

To avoid repeating these options, declare them in .sops.yaml, at the root of the repository. SOPS applies the first rule whose path_regex matches the file path; if no rule matches, the command fails with no matching creation rules found.

creation_rules:
# Production: encrypt only the secrets
- path_regex: prod/.*
age: age1abc...
encrypted_regex: '^(password|token|secret|key)$'
# Everything else: encrypt everything
- path_regex: .*
age: age1abc...

Injecting secrets without an intermediate file

sops decrypt > secrets.yaml leaves a file in clear on disk, in 0644 as measured above. SOPS offers two commands that avoid it: exec-env and exec-file. The secret then only exists in memory or in a named pipe of the launched process.

exec-env: secrets as environment variables

sops exec-env decrypts a flat file and launches the command with each key as an environment variable. The command is passed in single quotes, otherwise your shell would replace $DB_PASSWORD before SOPS launches it.

Terminal window
sops exec-env secrets.enc.env 'echo DB_PASSWORD=$DB_PASSWORD'
# DB_PASSWORD=super-secret-123

A nested YAML file is rejected, measured: cannot use complex value in environment; offending key database, with an exit code of 1. Keep exec-env for .env files or flat YAML.

exec-env --pristine: a clean environment

The --pristine option launches the command without the parent's environment: only the decrypted secrets are passed on. Measured, HOME is empty; PATH is /sbin:/usr/sbin:/bin:/usr/bin, a value the launched shell sets itself for lack of inheritance.

Terminal window
sops exec-env --pristine secrets.enc.env 'echo HOME=$HOME PATH=$PATH DB_PASSWORD=$DB_PASSWORD'
# HOME= PATH=/sbin:/usr/sbin:/bin:/usr/bin DB_PASSWORD=super-secret-123

The point is not to pass your session's variables to the command, such as an AWS_SECRET_ACCESS_KEY exported earlier. This protects against an accidental leak, not against a malicious script that would read other sources.

exec-file: secrets in a named pipe

sops exec-file makes the decrypted content available to the command through {}. By default, measured, {} is a named pipe (FIFO, prw------- permissions): nothing is written to disk. The --no-fifo option replaces it with a temporary file in 0600, deleted at the end, for tools that reread their file.

Terminal window
sops exec-file secrets.enc.yaml 'ls -l {}; head -3 {}'
# prw------- 1 root root 0 ... /tmp/.sops544678192/tmp-file
# # Base de production
# database:
# host: db.example.com
# Example: apply an encrypted Kubernetes manifest
sops exec-file k8s-secrets.enc.yaml 'kubectl apply -f {}'

exec-file accepts all formats, nested YAML included. A pipe can only be read once: a tool that opens its configuration file twice needs --no-fifo.

Modifying an encrypted file without an editor

The set, unset and decrypt --extract commands manipulate an encrypted file programmatically, without an editor. Paths follow a JSON syntax: '["database"]["password"]', and so do values, hence the double quotes around a string.

set: changing a value

sops set modifies the file in place; a missing key is created. The value must be valid JSON: a string is written '"new-password-456"', a number 5433.

Terminal window
sops set secrets.enc.yaml '["database"]["password"]' '"new-password-456"'
sops decrypt --extract '["database"]["password"]' secrets.enc.yaml
# new-password-456

Since SOPS 3.11, --value-stdin and --value-file keep the value out of the shell history. The JSON rule applies to them too, measured: a raw value is rejected with Value for --set is not valid JSON.

Terminal window
# ❌ Raw value: rejected
printf 'new-password' | sops set --value-stdin secrets.enc.yaml '["database"]["password"]'
# Value for --set is not valid JSON
# JSON value: accepted
printf '"new-password"' | sops set --value-stdin secrets.enc.yaml '["database"]["password"]'

unset: removing a key

sops unset removes a key and its value, in place. The parent is not cleaned up: removing both keys of api leaves an empty object, api: {}, visible in the measured output.

Terminal window
sops unset secrets.enc.yaml '["api"]["token"]'
sops unset secrets.enc.yaml '["api"]["enabled"]'
sops decrypt secrets.enc.yaml | tail -2
# - db1.example.com
# api: {}

extract: reading a value

decrypt --extract only returns the requested value or subtree, which suits scripts. A subtree is returned with its decrypted comments.

Terminal window
sops decrypt --extract '["database"]["password"]' secrets.enc.yaml
# super-secret-123
sops decrypt --extract '["database"]' secrets.enc.yaml
# host: db.example.com
# port: 5432
# username: admin
# # à tourner tous les 90 jours
# password: super-secret-123
# ...

Checking a file's status

sops filestatus tells whether a file is encrypted by SOPS, in JSON. It is the basis of a pre-commit hook that rejects a *.enc.yaml file left in clear, the most frequent mistake after a forgotten sops decrypt -i.

Terminal window
sops filestatus secrets.enc.yaml
# {"encrypted":true}
sops filestatus secrets.yaml
# {"encrypted":false}
#!/usr/bin/env bash
# .git/hooks/pre-commit: rejects a *.enc.yaml that is not encrypted
set -euo pipefail
while IFS= read -r file; do
if ! sops filestatus "$file" | grep -q '"encrypted":true'; then
echo "ERREUR : $file n'est pas chiffré" >&2
exit 1
fi
done < <(git diff --cached --name-only --diff-filter=ACM -- '*.enc.yaml')

To find out who can decrypt a file, there is no need to decrypt it: the recipients are in clear in the sops: block. A grep recipient secrets.enc.yaml lists the age public keys, and KMS ARNs appear under the kms key. The --show-all-keys option found in old tutorials does not exist: SOPS 3.13.3 answers flag provided but not defined.

Working as a team: several keys

For a team, several people must be able to decrypt. SOPS encrypts the data key for each recipient: everyone decrypts with their own private key, and everyone can be removed without affecting the others.

Adding several recipients

Recipients are separated by a comma, without spaces. Measured: the file then contains two envelopes, and each of the two private keys decrypts on its own.

Terminal window
sops encrypt --age age1alice...,age1bob... secrets.yaml > secrets.enc.yaml

The team .sops.yaml file

To avoid repeating keys, declare the recipients in .sops.yaml by path. A key group gathers alternative keys: any one of them is enough to recover the data key. AWS accounts have 12 digits in ARNs.

.sops.yaml
creation_rules:
# Production: ops team, or KMS key
- path_regex: secrets/prod/.*\.yaml$
key_groups:
- age:
- age1abc... # Alice (ops)
- age1def... # Bob (ops)
kms:
- arn: arn:aws:kms:eu-west-3:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab
# Development: dev team
- path_regex: secrets/dev/.*\.yaml$
age: age1ghi...,age1jkl...
Terminal window
# SOPS applies the rule that matches the path
sops encrypt secrets/prod/database.yaml > secrets/prod/database.enc.yaml

Removing a member: updatekeys is not enough

When someone leaves the team, you remove their key from .sops.yaml, then sops updatekeys synchronises the file. The measured output shows the removal. But the data key does not change, and that is the whole problem.

Terminal window
$ sops updatekeys -y team.enc.yaml
Syncing keys for file /w/team.enc.yaml
The following changes will be made to the file's groups:
Group 1
age1akfm23rhvguzrnezcw40m3kt6fyhftpgzzsu9fqxt6dhay6g7vxq7djamd
--- age1m06ql5n62vepz5ycvp8w3rl5gs7trygldqg5yu6qmxrlwj4sdslstlx9y0
File /w/team.enc.yaml synced with new keys

After this command, Bob can no longer decrypt the updated file (code 128). But the encrypted values are identical byte for byte, and Bob still has the old version of the file in the Git history. Measured: by pasting the old sops: block, which contains his envelope, onto the new values, Bob recovers the data key and decrypts the new file.

Terminal window
# Old sops: block (with Bob's envelope) + values of the updated file
SOPS_AGE_KEY_FILE=bob.txt sops decrypt --ignore-mac hybride.yaml | grep password
# password: super-secret-123

The countermeasure is sops rotate, which generates a new data key and re-encrypts all values. Measured: after sops rotate -i, the encrypted values change, and the same manipulation fails (cipher: message authentication failed). The SOPS documentation gives the order: remove the key, then renew the data key, then change the secrets themselves, which Bob could read while he was a recipient.

Terminal window
# 1. Remove the key from .sops.yaml, then synchronise
sops updatekeys -y secrets.enc.yaml
# 2. New data key, all values re-encrypted
sops rotate -i secrets.enc.yaml
# 3. Change the passwords and tokens at their providers

sops rotate also accepts --add-age and --rm-age to add or remove a recipient and change the data key in a single command.

Shamir threshold: requiring several teams

For critical secrets, SOPS splits the data key with Shamir's secret sharing: with shamir_threshold: 2 and three key groups, you must successfully decrypt with at least two groups out of three. No single person can then read the file.

.sops.yaml
creation_rules:
- path_regex: critical/.*
shamir_threshold: 2
key_groups:
- age:
- age1abc... # Ops team
- age:
- age1def... # Security team
- age:
- age1ghi... # Offline backup

Measured with a single key available, decryption fails with code 128:

Group 0: SUCCESS
Group 1: FAILED
Group 2: FAILED
Recovery failed because the file was encrypted with a Shamir threshold of
2, but only 1 part(s) were successfully recovered, one for each successful
key group. In order for SOPS to recover the file, at least 2 groups have to
be successful.

With the keys of two groups, the file decrypts. The threshold protects against an isolated person, at the cost of coordination at every read: keep Shamir for root secrets.

Encrypting with AWS KMS

On an AWS project, KMS avoids distributing private keys: the right to decrypt becomes an IAM policy, revocable without touching the files, and each Decrypt call is traced in CloudTrail, which age does not provide. This section follows the SOPS and AWS documentation; it was not replayed, for lack of an AWS account in the lab.

  1. Create a KMS key

    key/...
    aws kms create-key --description "SOPS encryption key" \
    --tags TagKey=Purpose,TagValue=SOPS
  2. Encrypt with KMS

    Terminal window
    sops encrypt --kms arn:aws:kms:eu-west-3:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab \
    secrets.yaml > secrets.enc.yaml
  3. Decrypt: SOPS uses the session's AWS credentials

    Terminal window
    sops decrypt secrets.enc.yaml

The trade-off is the network dependency: an offline workstation or an unavailable region blocks decryption. Placing an offline age key in the same key group as the KMS key, as in the team .sops.yaml above, keeps a fallback path.

Integrations

SOPS is only useful if it fits into the tools that consume secrets. The principle stays the same: the encrypted file is versioned, and only the private key is supplied at runtime, through an environment variable or an IAM role. Four integrations cover the frequent cases.

SOPS and Ansible

The community.sops collection decrypts SOPS files in playbooks. Any task that handles the value carries no_log: true, otherwise Ansible writes it to its output in verbose mode or on failure.

Terminal window
ansible-galaxy collection install community.sops
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
---
- name: Déployer avec secrets SOPS
hosts: all
vars:
secrets: "{{ lookup('community.sops.sops', 'secrets.enc.yaml') | from_yaml }}"
tasks:
- name: Écrire la configuration de connexion
ansible.builtin.template:
src: db.conf.j2
dest: /etc/app/db.conf
owner: app
group: app
mode: "0600"
no_log: true

SOPS and Kubernetes

The sops-secrets-operator turns a SopsSecret object, encrypted by SOPS, into a Kubernetes Secret. Its version 0.21.2 serves the isindir.github.com/v1alpha3 API. The whole resource is encrypted by sops encrypt, sops: block included, and the operator holds the decryption key in the cluster.

apiVersion: isindir.github.com/v1alpha3
kind: SopsSecret
metadata:
name: app-secrets
spec:
secretTemplates:
- name: db-credentials
stringData:
username: ENC[AES256_GCM,data:...,type:str]
password: ENC[AES256_GCM,data:...,type:str]
# sops:
# (block added by sops encrypt)

SOPS and CI/CD

A runner has no key in ~/.config: the private key arrives through the SOPS_AGE_KEY variable, which directly contains the key text. Both examples install SOPS with checksum verification, then launch the deployment with sops exec-env, with no decrypted file on disk.

.github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
permissions: {}
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Installer SOPS (empreinte vérifiée)
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.amd64
echo "e5bec3346a873ae91d871550f3e698c1aad962aff462a080e40f25fde17fef6b sops-v3.13.3.linux.amd64" | sha256sum --check
sudo install -m 0755 sops-v3.13.3.linux.amd64 /usr/local/bin/sops
sops --version --disable-version-check
- name: Déployer avec les secrets déchiffrés en mémoire
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: sops exec-env secrets/prod.enc.env './deploy.sh'

This workflow passes actionlint, zizmor 1.30.0 and poutine 1.1.6 with no findings. The SOPS_AGE_KEY secret is stored in the production environment, which is protected by a review rule.

The e5bec334... checksum comes from the sops-v3.13.3.checksums.txt file whose cosign signature was verified above. Writing it into the pipeline, rather than downloading the checksums file again, pins the expected binary: a release modified after the fact would make the job fail instead of pass.

Git diff integration

Git can show decrypted values in its diffs thanks to a textconv. Measured: after changing the port, git diff shows - port: 5432 and + port: 5433 instead of two ENC[...] blocks.

  1. Declare the filter in .gitattributes

    *.enc.yaml diff=sopsdiffer
    *.enc.json diff=sopsdiffer
  2. Configure Git

    Terminal window
    git config diff.sopsdiffer.textconv "sops decrypt"
  3. Check: git diff shows the values in clear for these files.

This setting decrypts the files on the machine that runs the diff. Only enable it on a workstation that holds the private key, never on a shared runner: reviewing the structure does not need the values.

Troubleshooting

Most SOPS errors come from three causes: a private key that cannot be found, a recipient missing from the file, or a change that breaks the MAC. The table maps each real message, recorded with SOPS 3.13.3, to its cause.

Measured messageCauseSolution
Failed to get the data key required to decrypt the SOPS fileNo private key foundCheck SOPS_AGE_KEY_FILE, SOPS_AGE_KEY or ~/.config/sops/age/keys.txt
no identity matched any of the recipientsYour key is not a recipientGet added, then sops updatekeys
MAC mismatch. File has ..., computed ...Key or line removed by handRestore from Git, edit with sops edit or sops set
cipher: message authentication failedEncrypted value altered or movedRestore from Git
cannot use complex value in environmentNested YAML with exec-env.env file, flat YAML, or exec-file
no matching creation rules foundNo path_regex matchesFix .sops.yaml or the file path
Recovery failed because the file was encrypted with a Shamir threshold of 2Not enough key groups availableGather the keys of at least shamir_threshold groups
Value for --set is not valid JSONRaw value passed to setWrap the string in double quotes
must specify --filename-override when reading from stdinStandard input without a file nameAdd --filename-override

Good practices

These rules solve the problems that appear after a few months as a team: a colleague's departure, a lost workstation, diverging conventions. Applying them from the start costs a few minutes; catching up means re-encrypting all the files and changing the secrets.

  • One key per person: each member has their own age key, so one person can be removed without affecting the others
  • Removal = updatekeys + rotate + new secrets: Git history keeps the old envelope
  • Back up private keys: a lost key makes the files unrecoverable
  • Versioned .sops.yaml: the whole team applies the same rules
  • Neutral key names in public repositories: they stay readable
  • KMS in production: IAM-based control and a log of decryptions, with a fallback age key

Separating dev/ and prod/ by directory lets you write distinct path_regex rules in .sops.yaml, hence different recipients per environment. The .enc.yaml suffix standardises filtering in Git hooks and in .gitattributes.

  • Répertoireproject/
    • .sops.yaml recipients configuration
    • Répertoiresecrets/
      • Répertoiredev/
        • database.enc.yaml
      • Répertoireprod/
        • database.enc.yaml
    • README.md instructions for the team

Resources

These official documentations complement the lesson's measurements. The SOPS documentation moved from the repository's README to getsops.io with version 3.13.1; the release notes flag behaviour changes from one version to the next.

Key points

  • SOPS encrypts values with one data key per file, itself encrypted for each recipient (age, KMS).
  • By default, everything is encrypted, comments included; key names, types, value lengths and recipients remain visible.
  • SOPS is installed by checking the cosign signature of the checksums file, then the binary's checksum.
  • age is recommended by the SOPS documentation; its post-quantum keys have worked since SOPS 3.12.0, at the cost of files almost four times larger.
  • exec-env and exec-file (named pipe by default) avoid the file in clear that --output creates in 0644.
  • updatekeys does not change the data key: to remove a member, chain updatekeys, sops rotate, then change the secrets.
  • A Shamir threshold requires several key groups; a single key gives Recovery failed.
  • The evidence expected by SOCLE-SEC-STO-2 is an inspection showing that no secret is in clear in the project files; SOPS does not trace reads, which STO-4 requires.

Knowledge check

Check that you have the essentials of this lesson. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn