CoursesAnsibleAnsible Vault & secrets

Ansible Vault & secrets

Encrypt secrets in your repo.

Advanced14 min · lesson 9 of 12

A Git repository is a filing cabinet that nobody ever empties. Delete a password from a file today, commit the change, and the old value is still sitting there: in the history, in every clone on every laptop, in every fork, in last night's backup, and in the cache of whatever CI (continuous integration, the robot that rebuilds and retests your code every time somebody pushes) runner touched the branch. Ansible Vault gets around that by never letting the plaintext in. It turns a secret into a block of hex (a long run of the characters 0 to 9 and a to f) that means nothing without a password, and you commit that block right next to the playbook that uses it.

Be clear about what you are buying. Vault is a padlock on a file, not a secrets manager. There is no rotation schedule, no per-secret access policy, no audit log telling you which engineer read the database password last Tuesday. You get one job done well: credentials that travel with the code, survive code review, and stay unreadable to anyone who clones the repo. For a small team that is often enough. For a bigger one, it is the sensible thing to run until you have somewhere better to keep the secrets.

What Vault Does to a File

Encrypting a file works like sealing a letter inside a thick, unmarked envelope. You cannot read what is inside, and you cannot tell what sort of thing it was. Start with an ordinary variables file and lock it.

terminal
cat group_vars/all/vault.yml
# encrypt in place: the plaintext is overwritten
ansible-vault encrypt group_vars/all/vault.yml
head -3 group_vars/all/vault.yml
wc -l group_vars/all/vault.yml
output
---
vault_db_password: "s3cr3t-pw"
vault_api_token: "glpat-9f3a1c7e2b4dK"
New Vault password:
Confirm New Vault password:
Encryption successful
$ANSIBLE_VAULT;1.1;AES256
65373830396363323237623739626132376661393033613266626264313438616566323263353937
3662303163663335336439336462613135336433356662310a323363316532356435353938653538
9 group_vars/all/vault.yml

The first line is the only part that is not ciphertext (scrambled output). $ANSIBLE_VAULT is the marker Ansible looks for when it loads a file, 1.1 is the envelope format version, and AES256 names the cipher. Everything below that line is hex, wrapped at 80 characters. Nine lines to hold two short secrets, and none of it tells a reader anything.

Underneath, your password goes through PBKDF2 (Password-Based Key Derivation Function 2, a deliberately slow way of turning a typed password into a cryptographic key). Here that means 10,000 rounds of HMAC-SHA-256 (a keyed fingerprinting function built on the SHA-256 hash), mixed with a salt: 32 random bytes drawn fresh every time and stored in the file itself. Three things come out. A 32-byte key for AES-256 (Advanced Encryption Standard at 256 bits) running in CTR (counter) mode, a second 32-byte key used only for authentication, and the 16-byte block that CTR mode starts counting from. Ansible encrypts first, then computes an HMAC over the finished ciphertext with that second key and stores it alongside. Change one hex character in a pull request and decryption fails loudly instead of quietly handing back garbage.

The random salt has a consequence you will meet on your first commit. Encrypt the same content twice and you get two completely different blobs. Every change to a vaulted file therefore lands as a diff in which every single line changed. A reviewer cannot tell whether you fixed a typo or slipped in five new credentials. That one fact drives most of the conventions below.

The Commands You Will Actually Type

ansible-vault has seven subcommands and you will meet all of them. create opens a new file in $EDITOR (the environment variable naming your text editor, usually vim or nano) and writes ciphertext when you save. encrypt converts an existing plaintext file in place. view prints the plaintext to your terminal without writing it anywhere. edit decrypts to a temporary file, hands that to your editor, then re-encrypts and shreds the temporary copy when you quit. rekey changes the password on one or more files in a single command, which is your rotation story. decrypt turns a file permanently back into plaintext, which you want when retiring a secret and almost never otherwise. encrypt_string is the odd one out and gets its own section.

terminal
# read the plaintext without writing it to disk
ansible-vault view group_vars/all/vault.yml
# rotate the password across several files at once
ansible-vault rekey group_vars/all/vault.yml roles/db/vars/secrets.yml
# encrypting an already-encrypted file is refused, not doubled
ansible-vault encrypt group_vars/all/vault.yml
output
Vault password:
---
vault_db_password: "s3cr3t-pw"
vault_api_token: "glpat-9f3a1c7e2b4dK"
Vault password:
New Vault password:
Confirm New Vault password:
Rekey successful
New Vault password:
Confirm New Vault password:
ERROR! input is already encrypted
edit leaves a decrypted copy on disk while you type
ansible-vault edit never puts plaintext in your repository, which is the whole point of it. It does put plaintext under ~/.ansible/tmp for as long as your editor is open, then overwrites and deletes that file when you quit. Your editor is less careful than Ansible is. vim drops a swap file next to whatever it has open, and if you have persistent undo switched on, that history outlives the session completely. On your own laptop the risk is small. On a shared jump host (one gateway server that a whole team logs into) where several accounts can read each other's home directories, it is real. Use ansible-vault view when you only need to read, and find out what your editor writes before you run edit on a machine you share.

The vault_ Prefix, and the Plaintext File Beside It

Because a vaulted file gives nothing away, the trick is to make it as boring as possible. One file holds nothing but secrets, every variable prefixed vault_. A second, plaintext file sits next to it and maps ordinary names onto those. Picture a locked drawer with a typed index taped to the front: the index tells you what is in there, the drawer still needs a key. Your playbooks, templates and roles only ever mention the names on the index.

group_vars/all/vault.yml (encrypted in Git, shown here via ansible-vault view)
---
# Nothing lives here except secrets. One obvious, encrypted place.
vault_db_password: "s3cr3t-pw"
vault_api_token: "glpat-9f3a1c7e2b4dK"
group_vars/all/vars.yml (plaintext in Git)
---
# Readable names, encrypted values. Roles and templates use these.
db_password: "{{ vault_db_password }}"
api_token: "{{ vault_api_token }}"

Two things get better. A reviewer reading vars.yml can see which secrets exist and which one you added, even though the values stay locked away, and that recovers some of what the all-lines-changed diff took from you. And grep -rn db_password roles/ still finds every place the secret is used, because the name is in plaintext everywhere except the one file holding the value. Precedence does not change. Both files sit in group_vars/all/, Ansible loads them exactly as it would if neither were encrypted, and Jinja (the templating language behind the {{ ... }} markers) resolves vault_db_password at the moment the value is needed, so the order the two files load in does not matter.

Encrypting One Value Instead of a Whole File

For two or three secrets scattered through otherwise readable configuration, locking a whole file is heavy-handed. ansible-vault encrypt_string encrypts a single value and prints a YAML (YAML Ain't Markup Language, the indented text format Ansible files are written in) snippet you paste into a plain file. Names and structure stay readable. Only the value is ciphertext.

Do not pass the secret as a command-line argument. It lands in your shell history, and while the command runs it is visible in the process list to every user on the box who types ps. Use --stdin-name instead. It reads the value from standard input (the stream you pipe into a command) and names the variable for you.

terminal
# the secret arrives on stdin, so it misses shell history and ps output
printf 's3cr3t-pw' | ansible-vault encrypt_string --stdin-name 'db_password'
output
New Vault password:
Confirm New Vault password:
Reading plaintext input from stdin. (ctrl-d to end input, twice if your content does not already have a newline)
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
33663261333263653763613037643738363739313532323666353961363063326230383639366333
3633386139386562323462353561306562633236386262310a613239333863373432373339303132
61643634643835393037323638613465326361346561613234623035366536653766666536636236
3535393661336337370a336633343439323135343761616634353032386263376166343130646661
3030
Encryption successful

!vault is a YAML tag telling Ansible that this string needs decrypting before use, and it works anywhere variables are defined: group_vars, host_vars, vars: inside a play, role defaults. One behaviour catches people out. view and edit only understand whole-file vaults. Point either at a plaintext file that happens to contain inline !vault strings and it refuses, after asking you for the password first. It also prints the filename twice, which is Ansible's doing and not a mistake on your side.

terminal
ansible-vault view group_vars/all/vars.yml
output
Vault password:
ERROR! input is not vault encrypted data. group_vars/all/vars.yml is not a vault encrypted file for group_vars/all/vars.yml

Handing the Password Over at Run Time

At play time Ansible needs that password, and there are three ways to hand it over. --ask-vault-pass prompts you, which is fine at a keyboard and useless in a pipeline. --vault-password-file reads it from a file. The part people miss is what happens when that file is executable: Ansible runs it and takes whatever it prints on standard output as the password. Your CI runner then never stores the vault password on disk at all, because the file is a short script that calls your secrets manager and prints the answer. Get any of this wrong and the run stops with exit code 4, after the play banner and before anything touches a managed host.

terminal
# no password supplied at all
ansible-playbook site.yml; echo "rc=$?"
# a password, but the wrong one
ansible-playbook site.yml --vault-password-file ~/.dev_pass; echo "rc=$?"
# type it yourself
ansible-playbook site.yml --ask-vault-pass
output
PLAY [Ship app config] *********************************************************
ERROR! Attempting to decrypt but no vault secrets found
rc=4
PLAY [Ship app config] *********************************************************
ERROR! Decryption failed (no vault secrets were found that could decrypt) on /srv/deploy/group_vars/all/vault.yml
rc=4
Vault password:
PLAY [Ship app config] *********************************************************
TASK [Render the app config] ***************************************************
changed: [web01]
PLAY RECAP *********************************************************************
web01 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
~/bin/vault-pass.sh (chmod 0700)
#!/bin/sh
# Executable password "file": Ansible runs it and reads stdout.
# The vault password never lands on the runner's disk.
set -eu
aws secretsmanager get-secret-value \
--secret-id ansible/vault-password \
--query SecretString --output text
ansible.cfg
[defaults]
inventory = inventory.ini
# Ansible finds the password by itself, so nobody is tempted to
# paste it into a shell one-liner that lands in history.
vault_password_file = ~/bin/vault-pass.sh

Vault IDs, When One Key Is Not Enough

One password for the whole repository means the contractor who needs the staging database password is holding the production one too. Vault IDs put a label on each encrypted blob, the way a hotel writes a room number on a key fob, so a single run can carry several keys and reach for the right one per file. Encrypt with --vault-id prod@prompt and the header changes: envelope version 1.2, with the label appended in the clear.

terminal
printf 'prod-pw' | ansible-vault encrypt_string --vault-id prod@prompt --stdin-name 'db_password'
output
New vault password (prod):
Confirm new vault password (prod):
Reading plaintext input from stdin. (ctrl-d to end input, twice if your content does not already have a newline)
db_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;prod
35653465386539636666646564363764333535343039613134346439633237373164616163616161
6538353839386630353465316635313537333738633832620a333635373830356162626239303363
63313135663439623664333732353466393331326536396364353562613530353435353437653538
3432616335376633630a636661656161323336393364323939643734396634376364323735306233
3234
Encryption successful

At run time you pass one --vault-id per key. label@path reads a file, label@prompt asks you. When more than one identity is loaded and you are encrypting, --encrypt-vault-id says which of them to use. Put the everyday set in ansible.cfg under vault_identity_list and the flags disappear from your muscle memory. Now try opening a production blob with a key that carries a different label.

terminal
# this blob was encrypted under the label "prod"
head -1 group_vars/all/vault.yml
# open it with a key labelled "staging" that holds the same password
ansible-playbook site.yml --vault-id staging@~/.prod_pass
output
$ANSIBLE_VAULT;1.2;AES256;prod
PLAY [Ship app config] *********************************************************
TASK [Render the app config] ***************************************************
changed: [web01]
PLAY RECAP *********************************************************************
web01 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
A vault ID is a label, not a lock
That run succeeded, and it should bother you. vault_id_match is off by default, so Ansible tries every password it holds against every blob and stops at the first one that works. The label in the header is a hint that saves a lookup, not a boundary, and nothing about it is signed or checked. Set vault_id_match = True under [defaults] in ansible.cfg and the same command fails with exit code 4 and ERROR! Decryption failed (no vault secrets were found that could decrypt). Be honest about what that buys you. It catches accidents, like a secret encrypted with the dev key sitting in a file everyone believes is production-only. It stops nobody who actually holds the password.

Decrypted Is Decrypted

Vault protects a secret while it sits in the repo. The moment a play runs, that secret is a plain string in memory, in module arguments on the target, in task results, and in whatever a callback plugin (the component deciding what Ansible prints and what it ships to a log collector) chooses to show. The most common way a vaulted password escapes is not a broken cipher. It is --diff.

terminal
ansible-playbook site.yml --diff
output
PLAY [Ship app config] *********************************************************
TASK [Render the app config] ***************************************************
--- before
+++ after: /root/.ansible/tmp/ansible-local-199jw3udj23/tmp78i0nhzb/app.conf.j2
@@ -0,0 +1,2 @@
+[database]
+password = s3cr3t-pw
changed: [web01]
PLAY RECAP *********************************************************************
web01 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

There is the production database password, sitting in a pipeline log, in whatever aggregator ships that job's output, readable by everyone with access to the build. Nothing was misconfigured. --diff did exactly what it exists to do. The rendered file also lands on the managed host as plaintext, which is why the mode: on that task matters as much as the encryption did.

no_log: true is the fix, and it works one task at a time. Ansible throws the entire result away, arguments included, and returns a censored placeholder instead.

site.yml
- name: Ship app config
hosts: web
become: true
gather_facts: false
tasks:
- name: Render the app config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
owner: root
group: root
mode: "0640" # the rendered file holds the secret in the clear
no_log: true # nothing from this task reaches stdout or a callback
terminal
# same play, same --diff flag, only no_log added to the task
ansible-playbook site.yml --diff
# later, after someone deleted /etc/app on the target: the task now fails
ansible-playbook site.yml --diff
output
PLAY [Ship app config] *********************************************************
TASK [Render the app config] ***************************************************
changed: [web01]
PLAY RECAP *********************************************************************
web01 : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
PLAY [Ship app config] *********************************************************
TASK [Render the app config] ***************************************************
fatal: [web01]: FAILED! => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this result", "changed": false}
PLAY RECAP *********************************************************************
web01 : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0

That censored line is the price, and it is a real one. When a no_log task fails you get no message, no stderr, no module output, only the word censored and a red line. Debugging means reproducing the failure with a fake secret and no_log switched off, on a host whose logs you do not mind. Take the deal for anything holding a credential, but set it on the two tasks that touch the password rather than across the whole play, or you will be blind everywhere at once.

Do not lean on Ansible's built-in scrubbing to save you, because it is narrower than its reputation. Two things happen on their own. When a module declares a parameter as secret in its own argument spec, the way ansible.builtin.user declares password, that value comes back as the literal string VALUE_SPECIFIED_IN_NO_LOG_PARAMETER. And when a module shells out, any argument beginning pass, -pass, --password or --passwd gets masked in the line written to the managed host's system log, and in the cmd field handed back if the command fails to start. Read that last condition twice. On a run that succeeds, the registered cmd is the raw argument list with the password sitting in it, and stdout is never touched at all.

leak.yml
- name: Leak demo
hosts: web
gather_facts: false
tasks:
- name: Register the runner
ansible.builtin.command: /usr/local/bin/register --token={{ api_token }}
ignore_errors: true
terminal
ansible-playbook leak.yml
output
PLAY [Leak demo] ***************************************************************
TASK [Register the runner] *****************************************************
fatal: [web01]: FAILED! => {"changed": false, "cmd": "/usr/local/bin/register --token=glpat-9f3a1c7e2b4dK", "msg": "[Errno 2] No such file or directory: b'/usr/local/bin/register'", "rc": 2, "stderr": "", "stderr_lines": [], "stdout": "", "stdout_lines": []}
...ignoring
PLAY RECAP *********************************************************************
web01 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=1

--token= is not --password=, so the whole GitLab token is in the log. Spell the same flag --password= and it would have come back as '--password=********', which is worth seeing once so that you stop trusting it: the masking follows the spelling of the flag, never the sensitivity of the value. The same gap covers -p, --api-key, a token handed over in an environment variable, and anything the command prints on its way out. no_log is the only control that covers all of them.

Where Vault protects a secret, and where it stops
Ciphertext, safe to commit
Vaulted files in Git
whole-file vaults and inline !vault strings
Clones, forks, backups
hex is useless without the password
Pull request diffs
reviewers see the names, never the values
Plaintext the moment you run
Module arguments
shipped to the target on every task
Task results
stdout, -vvv, --diff, callback plugins
Rendered templates
written to the managed host in the clear
ansible-vault view
plaintext straight into terminal scrollback
Vault never covered these
The vault password
CI secret store, keyring, or a human head
History from before you encrypted
git log -S still finds the old value
Backups of the managed host
copies of whatever you rendered into /etc
Anyone who can run the playbook
they hold the key by definition
The vault password is the whole security model. Wherever that password lives is the real edge of your protection.
10,000 PBKDF2 rounds is not much in 2026
The password-to-key step is 10,000 iterations of PBKDF2 with SHA-256. Current guidance for that exact construction is around 600,000. Nobody is breaking AES-256 here. But anybody who clones the repository can attack the vault password itself offline, on their own hardware, at their own pace, with no rate limit and no alert that ever reaches you. A dictionary word with a couple of digits on the end will not survive that. Have a machine generate a long random passphrase, keep it in a password manager or a CI secret store, and run ansible-vault rekey across your vaulted files if the password you have now came out of somebody's head.

Prove You Actually Encrypted It

Do not trust the word successful. Check the repository the way somebody who stole it would, by searching for the value rather than for the file.

terminal
# every vaulted file, found by its marker
grep -rl '^\$ANSIBLE_VAULT' group_vars/ roles/
# now hunt for the plaintext among tracked files
git grep -n 'glpat-'; echo "rc=$?"
output
group_vars/all/vault.yml
roles/db/vars/secrets.yml
rc=1

An exit code of 1 from git grep means no match, which is the answer you want. A clean working tree is only half the check. Git keeps everything, so put the same question to the history with -S, which finds commits where the number of occurrences of a string changed.

terminal
git log --oneline -S 'glpat-9f3a1c7e2b4dK'
output
8caee1c encrypt secrets
8b6fdb5 add config

Two commits. One added the token in plaintext, the other encrypted it, and git show 8b6fdb5 prints the value in full to anyone who can clone the repo. Encrypting a secret that has already been committed hides today's copy and leaves the working one in history forever. So when that search comes back with results, the vault work is the second job. The first is rotating the credential at the system that issued it, so the copy sitting in your history stops being a key and becomes a string.

Quick check
01You change one character inside a vaulted group_vars/all/vault.yml and commit. The pull request shows every line of the file as modified. Why?
Incorrect — after encryption there is no YAML left in the file to re-indent, only a header line and hex.
Incorrect — Git diffs text files line by line at any size, and a vaulted vars file is a few hundred bytes anyway.
Correct — the salt is random per encryption, which makes the whole blob different even when the plaintext barely changed.
Incorrect — the first line is the plaintext $ANSIBLE_VAULT header, and the HMAC sits inside the hex body where it has no bearing on how Git compares lines.
02A file was encrypted with --vault-id prod@prompt, so its header reads $ANSIBLE_VAULT;1.2;AES256;prod. A colleague runs the playbook with --vault-id staging@~/.staging_pass, and the staging password happens to be the same string as the prod one. What happens on a default Ansible install?
Correct — label matching is not enforced unless you turn it on, so the password alone decides.
Incorrect — that is what vault_id_match = True does, and it is off out of the box.
Incorrect — there is no such warning, and at normal verbosity the mismatch is completely silent.
Incorrect — the label in a 1.2 header is an unauthenticated hint for picking a password, not a requirement of the format.
03A CI job log for a template task shows +password = s3cr3t-pw in its diff output. The value comes from a correctly vaulted group_vars/all/vault.yml, and that file was never committed in the clear. What is the right response?
Incorrect — the vault password never appeared in that log, and rekeying leaves the exposed database password valid.
Incorrect — dropping --diff prevents the next leak but does nothing about the password already sitting in the log and its backups.
Incorrect — there is no redaction in diff output, so that is the real password rendered into the file.
Correct — the credential is spent the moment it reaches a log, so you replace it first and then close the hole that printed it.

Try this

Run cat group_vars/all/vault.yml 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: edit leaves a decrypted copy on disk while you type. 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