Skip to main content
RunBook Academy

AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters

Lookups that touch secrets

Advanced⏱ ~21 minansible-playbookansible-doc

What you'll learn

  • Choose the right lookup for each source of secret material
  • State what the password lookup writes to disk and why that surprises people
  • Explain why merge rights on a repository are controller shell access when pipe is permitted
  • Review a playbook for controller-side execution introduced through an expression

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Lesson 2 established that lookups run on the controller. That is the machine holding SSH keys to the whole fleet and the vault password that decrypts your secrets — so anything that reads secrets, or executes code, does it there, with your privileges.

This lesson covers the five ansible.builtin lookups that handle credential material, and it spends most of its length on the two that behave in ways people do not expect.

The five

LookupReadsWatch for
envA controller environment variableAbsent variables yield empty, not an error
fileA controller fileVault-encrypted files come back as ciphertext
unvaultA vault-encrypted controller file, decryptedNeeds the vault password available
passwordGenerates a password — and writes it to a fileThe side effect
pipeThe output of a shell command on the controllerIt is a shell

env

Read-only / Safereading CI context
- ansible.builtin.debug:
  msg: "deploying {{ lookup('ansible.builtin.env', 'CI_COMMIT_SHA') }}"

Right for anything the pipeline puts in the environment. Two properties to hold on to: a variable that is not set yields an empty string rather than failing, and the environment read is the controller’s — including on CI runners, where the environment is whatever the pipeline injected and may include far more than you intended a playbook to see.

file and unvault

file returns bytes as they are on disk. Point it at a vault-encrypted file and you get the ciphertext, headed $ANSIBLE_VAULT;1.1;AES256 — which is a surprisingly common way to deploy an encrypted blob to a host by accident, since it does not error.

unvault decrypts first, using whatever vault password the run has available:

Read-only / Safereading an encrypted file's plaintext
- ansible.builtin.copy:
  content: "{{ lookup('ansible.builtin.unvault', 'files/api-key.vault') }}"
  dest: /etc/app/api-key
  owner: app
  group: app
  mode: '0400'
  no_log: true

no_log: true is not optional there. Part XXII covers why in full; the short version is that without it the decrypted value appears in -vvv output and in the task result.

password — the one with a side effect

Read-only / Safepw.yml
- ansible.builtin.debug:
  msg: "len={{ lookup('ansible.builtin.password', 'pw/demo length=20') | length }}"

- ansible.builtin.debug:
  msg: "same={{ lookup('ansible.builtin.password', 'pw/demo length=20')
                == lookup('ansible.builtin.password', 'pw/demo length=20') }}"
Configuration changeit generates once and remembers
$ ansible-playbook -i localhost, pw.yml
TASK [password lookup first call] **********************************************
ok: [localhost] => {
  "msg": "len=20"
}

TASK [password lookup second call] *********************************************
ok: [localhost] => {
  "msg": "same=True"
}

The second call returns the same password as the first. It has to, or every run would rotate every credential. And the mechanism by which it remembers is the part people miss:

Read-only / Safethe side effect
$ ls -la pw/ && wc -c pw/demo
total 4
drwxr-xr-x  2 opsuser opsuser  60 Aug 11 22:45 .
drwxr-xr-x 13 opsuser opsuser 540 Aug 11 22:45 ..
-rw-------  1 opsuser opsuser  21 Aug 11 22:45 demo
21 pw/demo

The path argument is not a key or an identifier. It is a file, and the password is written into it in plaintext, mode 0600, on the controller.

pipe — the one that is a shell

Read-only / Safepipe runs on the controller, as you
$ ansible-playbook -i localhost, pipe.yml
TASK [pipe executes on the controller] *****************************************
ok: [localhost] => {
  "msg": "opsuser"
}

TASK [pipe with a shell metachar] **********************************************
ok: [localhost] => {
  "msg": "one\ntwo"
}

echo one && echo two produced both lines. The && was interpreted, which means this is a shell — not an execve of one binary with arguments. Everything the Linux course says about shell quoting, command substitution and set -o pipefail applies, on the controller, in the security context of the person who ran the playbook.

External secret managers

Every major secret store ships a lookup in its collection — HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, CyberArk, and others. They are all the same shape and all subject to the same three properties, which is more useful to know than any one of their option lists:

1. The controller authenticates, not the target. The credential that unlocks the secret store lives on the controller. That is usually right — the managed node should not be able to read the store — and it concentrates yet more privilege on the controller.

2. The fetch happens during templating. Before the task runs, on every host the expression is evaluated for. A lookup inside a task looped over 300 hosts can be 300 calls to your secret store, and rate limits are real. Fetch once into a variable with set_fact or a play-level vars, and reuse it.

3. no_log is your responsibility. The lookup returns a string. Nothing marks it secret; nothing redacts it downstream. A task whose parameters contain a fetched secret and no no_log: true prints it at -vvv.

Read-only / Safefetch once, use many
- name: Fetch the credential once for the whole play
ansible.builtin.set_fact:
  db_password: "{{ lookup('community.hashi_vault.hashi_vault', 'secret=secret/data/app:password') }}"
run_once: true
delegate_to: localhost
no_log: true

- name: Use it
ansible.builtin.template:
  src: app.conf.j2
  dest: /etc/app/app.conf
  owner: app
  mode: '0400'
no_log: true

Part XXIII covers external secret managers as an architecture. Here the point is narrower: whichever one you use, it is a lookup, so it runs on the controller and everything in this lesson applies.

Knowledge check

Knowledge check · 4 questions

  1. Q1. What does lookup('ansible.builtin.password', 'credentials/db_password length=20') do with its path argument?

  2. Q2. Why does permitting lookup('pipe', ...) in a repository change who may approve changes to it?

  3. Q3. Which are true of secret-store lookups such as the HashiCorp Vault one? Select all that apply.

  4. Q4. lookup('file', ...) pointed at a vault-encrypted file returns ciphertext, and deploying that ciphertext to a host raises no error.

Passing score: 75%. Answers are checked in this browser.