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-envandexec-file, without a file in clear - Share secrets as a team: several keys,
updatekeys,rotateand 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.
| Criterion | SOPS + age | Ansible Vault | HashiCorp Vault / OpenBao |
|---|---|---|---|
| Storage | Git files | Git files | Centralised service |
| Readable diffs | Yes, keys in clear | No for a whole file; encrypt_string encrypts one variable | Not applicable |
| Infrastructure | None | None | Server, high availability |
| Several recipients | Native, one key per person | Separate passwords per vault ID | Access policies |
| Audit of reads | No (except the KMS log) | No | Yes |
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.
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.
| Backend | Typical use | Advantage |
|---|---|---|
| age | Teams, personal projects | Simple, offline, no service |
| AWS KMS | AWS projects | IAM control, CloudTrail log |
| GCP KMS | GCP projects | IAM control |
| Azure Key Vault | Azure projects | Entra ID control |
| Vault / OpenBao (Transit) | Vault already in place | Encryption 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.
# Binary, checksums file and signature of the checksums filecurl -sLO https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.linux.amd64curl -sLO https://github.com/getsops/sops/releases/download/v3.13.3/sops-v3.13.3.checksums.txtcurl -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 checksumsha256sum --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/sopssops --version --disable-version-check# sops 3.13.3# Official Alpine image, pinned by digest (without GnuPG or cloud tools)docker run --rm -v "$PWD":/w -w /w \ ghcr.io/getsops/sops:v3.13.3-alpine@sha256:ae501277bf742f1662e0f881f43dd8fd6798b489a8058e921dbf6cda597140ea \ --version --disable-version-check# macOSbrew install sops# Windows (Scoop)scoop install sopsThese package managers install the latest published version: note it with
sops --version and align it with the one used in CI.
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.
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.2echo "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.gzsudo install -m 0755 age/age age/age-keygen /usr/local/bin/age --version# v1.3.2Post-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.
-
Create the keys directory
Terminal window mkdir -p ~/.config/sops/age -
Generate a key
Terminal window age-keygen -o ~/.config/sops/age/keys.txt# Public key: age1rxzcv6lwdvh0k9xyr442v70j4jpkmldchf4xpx5rqeq0elx44y3sa6thtl -
Note the public key
The public key (
age1...) is used to encrypt;age-keygen -y keys.txtprints it again at any time. The private key stays inkeys.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.
cat > secrets.yaml <<'EOF'# Base de productiondatabase: host: db.example.com port: 5432 username: admin password: super-secret-123 # à tourner tous les 90 jours replicas: - db1.example.comapi: token: sk-1234567890abcdef enabled: trueEOF
sops encrypt --age age1rxzcv6lwdvh0k9xyr442v70j4jpkmldchf4xpx5rqeq0elx44y3sa6thtl \ secrets.yaml > secrets.enc.yamlThe 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.3What 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 visible | Measured example |
|---|---|
| Key names and hierarchy | database.password, api.token |
| Type of each value | type:str, type:int, type:bool, type:comment |
| Number of items in a list | replicas contains one entry |
| Length of each value | 1 character gives data:dg==, 40 characters give 40 encrypted bytes |
| Recipients | age public keys, ARNs of KMS keys |
| Metadata | lastmodified 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.
# With the key in the default locationsops decrypt secrets.enc.yaml
# With a key file elsewhereSOPS_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.yamlWith 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.
# Opens the decrypted file in the editor, re-encrypts on saveSOPS_EDITOR=vim sops edit secrets.enc.yamlOther 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.
cat > secrets.env <<'EOF'DB_HOST=db.example.comDB_PASSWORD=super-secret-123API_TOKEN=sk-1234567890abcdefEOFsops 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.3sops encrypt --age age1... config.json > config.enc.jsonsops decrypt config.enc.json# A certificate, an SSH key: treated as binarysops encrypt --age age1... certificate.pem > certificate.enc.pem# {# "data": "ENC[AES256_GCM,data:FjaN6z+6od...,type:str]",# "sops": { ... }# }sops decrypt certificate.enc.pem > certificate.pemIn-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.
# Encrypt in placesops encrypt -i --age age1... secrets.yaml
# Standard input: --filename-override sets the format and the .sops.yaml ruleecho '{"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 filesops decrypt secrets.enc.yaml --output secrets.yamlThe 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.
sops encrypt --age age1... \ --encrypted-regex '^(password|token)$' \ secrets.yaml > secrets.enc.yaml# Base de productiondatabase: 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.comapi: token: ENC[AES256_GCM,data:qmzPg5zuWmBv6SITwoVemljqiA==,iv:...,type:str] enabled: trueunencrypted_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.
# Encrypt everything except host, username and portsops encrypt --age age1... --unencrypted-regex '^(host|username|port)$' secrets.yaml
# Encrypt only the keys ending in _secretsops encrypt --age age1... --encrypted-suffix '_secret' config.yaml# db_secret: ENC[AES256_GCM,...]# host: xConfiguring 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.
sops exec-env secrets.enc.env 'echo DB_PASSWORD=$DB_PASSWORD'# DB_PASSWORD=super-secret-123A 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.
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-123The 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.
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 manifestsops 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.
sops set secrets.enc.yaml '["database"]["password"]' '"new-password-456"'sops decrypt --extract '["database"]["password"]' secrets.enc.yaml# new-password-456Since 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.
# ❌ Raw value: rejectedprintf 'new-password' | sops set --value-stdin secrets.enc.yaml '["database"]["password"]'# Value for --set is not valid JSON
# JSON value: acceptedprintf '"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.
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.
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.
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 encryptedset -euo pipefailwhile IFS= read -r file; do if ! sops filestatus "$file" | grep -q '"encrypted":true'; then echo "ERREUR : $file n'est pas chiffré" >&2 exit 1 fidone < <(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.
sops encrypt --age age1alice...,age1bob... secrets.yaml > secrets.enc.yamlThe 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.
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...# SOPS applies the rule that matches the pathsops encrypt secrets/prod/database.yaml > secrets/prod/database.enc.yamlRemoving 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.
$ sops updatekeys -y team.enc.yamlSyncing keys for file /w/team.enc.yamlThe following changes will be made to the file's groups:Group 1 age1akfm23rhvguzrnezcw40m3kt6fyhftpgzzsu9fqxt6dhay6g7vxq7djamd--- age1m06ql5n62vepz5ycvp8w3rl5gs7trygldqg5yu6qmxrlwj4sdslstlx9y0File /w/team.enc.yaml synced with new keysAfter 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.
# Old sops: block (with Bob's envelope) + values of the updated fileSOPS_AGE_KEY_FILE=bob.txt sops decrypt --ignore-mac hybride.yaml | grep password# password: super-secret-123The 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.
# 1. Remove the key from .sops.yaml, then synchronisesops updatekeys -y secrets.enc.yaml# 2. New data key, all values re-encryptedsops rotate -i secrets.enc.yaml# 3. Change the passwords and tokens at their providerssops 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.
creation_rules: - path_regex: critical/.* shamir_threshold: 2 key_groups: - age: - age1abc... # Ops team - age: - age1def... # Security team - age: - age1ghi... # Offline backupMeasured with a single key available, decryption fails with code 128:
Group 0: SUCCESSGroup 1: FAILEDGroup 2: FAILED
Recovery failed because the file was encrypted with a Shamir threshold of2, but only 1 part(s) were successfully recovered, one for each successfulkey group. In order for SOPS to recover the file, at least 2 groups have tobe 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.
-
Create a KMS key
key/... aws kms create-key --description "SOPS encryption key" \--tags TagKey=Purpose,TagValue=SOPS -
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 -
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.
ansible-galaxy collection install community.sopsexport 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: trueSOPS 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/v1alpha3kind: SopsSecretmetadata: name: app-secretsspec: 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.
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.
stages: - deploy
deploy: stage: deploy image: alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH before_script: - apk add --no-cache curl - 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 -c - install -m 0755 sops-v3.13.3.linux.amd64 /usr/local/bin/sops script: # SOPS_AGE_KEY: protected and masked CI/CD variable, defined in the project - sops exec-env secrets/prod.enc.env './deploy.sh'The SOPS_AGE_KEY variable is declared in the project's CI/CD settings,
protected (only served to protected branches) and masked. No need to
redeclare it in variables:: GitLab already injects it into the job.
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.
-
Declare the filter in
.gitattributes*.enc.yaml diff=sopsdiffer*.enc.json diff=sopsdiffer -
Configure Git
Terminal window git config diff.sopsdiffer.textconv "sops decrypt" -
Check:
git diffshows 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 message | Cause | Solution |
|---|---|---|
Failed to get the data key required to decrypt the SOPS file | No private key found | Check SOPS_AGE_KEY_FILE, SOPS_AGE_KEY or ~/.config/sops/age/keys.txt |
no identity matched any of the recipients | Your key is not a recipient | Get added, then sops updatekeys |
MAC mismatch. File has ..., computed ... | Key or line removed by hand | Restore from Git, edit with sops edit or sops set |
cipher: message authentication failed | Encrypted value altered or moved | Restore from Git |
cannot use complex value in environment | Nested YAML with exec-env | .env file, flat YAML, or exec-file |
no matching creation rules found | No path_regex matches | Fix .sops.yaml or the file path |
Recovery failed because the file was encrypted with a Shamir threshold of 2 | Not enough key groups available | Gather the keys of at least shamir_threshold groups |
Value for --set is not valid JSON | Raw value passed to set | Wrap the string in double quotes |
must specify --filename-override when reading from stdin | Standard input without a file name | Add --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
Recommended repository structure
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.
- Official SOPS documentation: usage, identities, key management
- getsops/sops: source code, releases and release notes
- age: encryption tool and key format
- community.sops: Ansible collection
- sops-secrets-operator: Kubernetes operator
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-envandexec-file(named pipe by default) avoid the file in clear that--outputcreates in 0644.updatekeysdoes not change the data key: to remove a member, chainupdatekeys,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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Kyverno, the Kubernetes policy engine in YAML and CEL
A new full quiz with random questions
Work again only on the questions you missed