Ansible is the quiet workhorse of modern infrastructure. It configures servers, deploys applications, patches fleets, and orchestrates releases, often with a handful of readable YAML files. That accessibility is exactly why Ansible security deserves deliberate attention: a playbook that touches hundreds of hosts with escalated privileges is one of the most powerful, and most dangerous, artifacts in your environment. A single leaked credential or an unvalidated variable passed to a shell command can turn convenient automation into a fast path for an attacker.
The good news is that securing Ansible is largely about discipline and a set of well-understood Ansible security best practices. This deep dive walks through the areas that matter most: protecting secrets with Ansible Vault, keeping credentials out of playbooks and logs, applying least privilege with become, choosing safe modules, defending against template injection, hardening connections, managing your automation supply chain, and scanning everything in CI so secure Ansible playbooks become the default rather than the exception. It is part of our broader Infrastructure as Code security guide, which ties these tool-specific practices together.
Secrets management with Ansible Vault
Automation needs credentials: database passwords, API tokens, TLS keys, cloud access keys. The cardinal rule is simple. Sensitive values must never sit in plaintext in a repository. Ansible Vault is the built-in answer. It encrypts variables and files with a symmetric key so they can live safely alongside your playbooks.
You can encrypt an entire file:
ansible-vault encrypt group_vars/production/secrets.yml
Or encrypt individual strings inline so most of a vars file stays readable in diffs:
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
66386439653......
A few habits make Vault genuinely effective rather than theater:
Never commit the Vault password itself. Supply it at runtime with --vault-password-file pointing at a file outside the repo, or a script that fetches the key from your secrets platform. Keep the password out of shell history and CI logs.
Separate encrypted from cleartext data. A common pattern is a readable vars.yml that references encrypted values held in a vault.yml, so reviewers can see structure without exposing secrets.
Rotate keys and re-key when people leave. ansible-vault rekey re-encrypts content under a new password without changing the plaintext.
Vault is excellent for static secrets, but for credentials that rotate frequently or are shared across many systems, integrate an external secret store. Ansible can pull secrets at runtime from managed vaults and cloud secret managers via lookup plugins, so nothing sensitive is ever written to disk:
- name: Fetch API token from an external secret store at runtime
ansible.builtin.debug:
msg: "{{ lookup('community.hashi_vault.vault_kv2_get', 'apps/payments').secret.api_token }}"
no_log: true
This keeps the source of truth in a system built for rotation, auditing, and access control, while Ansible simply requests what it needs when it runs. It also shrinks the number of long-lived secrets you have to manage manually, which is where drift and stale credentials usually creep in.
A practical rule of thumb: use Vault for values that change rarely and belong with the code (feature flags, service configuration, seed credentials for a fresh environment), and use an external store for anything shared across teams, rotated on a schedule, or subject to compliance auditing. Mixing the two is normal and expected; the important thing is that no unencrypted secret ever reaches a commit, a log line, or a CI artifact.
Keeping secrets out of playbooks, inventory, and logs
Encryption only helps if secrets do not leak elsewhere. Two quiet failure modes cause most incidents.
First, inventory and playbook sprawl. Connection passwords in a plaintext inventory file, tokens hardcoded in a task, or private keys checked into version control are all common. Keep credentials in Vault-encrypted vars or an external store, and add inventory secrets to .gitignore patterns so they cannot be committed by accident.
Second, logs. Ansible echoes task results by default, and a task that handles a password can print it straight to the console or CI output. Use no_log: true on any task that touches sensitive data:
- name: Create database user
community.postgresql.postgresql_user:
name: app
password: "{{ db_password }}"
no_log: true
Be mindful that verbose modes and callbacks can still surface data, so avoid passing secrets through command/shell where they may appear in process listings or logs, and prefer modules that accept credentials as parameters.
Privilege and become: least privilege by default
Ansible's privilege escalation is powerful and easy to over-apply. Setting become: true at the play level so every task runs as root is convenient and risky. If any task is compromised or misbehaves, it does so with full root authority.
Apply least privilege instead:
Escalate only where needed. Put become: true on the individual tasks that require it, not the whole play.
Escalate to the right user, not always root. Use become_user to run tasks as the specific service account that owns the resource.
Scope sudo tightly. On managed hosts, grant the automation account sudo rights only for the specific commands it needs rather than blanket ALL access.
- name: Restart the app service (scoped escalation)
ansible.builtin.systemd:
name: myapp
state: restarted
become: true
become_user: root
The goal is that a runaway or hijacked task has the smallest possible blast radius.
It also helps to separate the account Ansible connects as from the privileges it escalates to. Connect as an unprivileged automation user over SSH, then escalate only for the specific tasks that need it. That way a stolen SSH key does not automatically equal root on every managed host, and your sudo policy on those hosts becomes an auditable second line of defense that you control independently of the playbooks themselves.
Module safety: prefer modules over shell
Ansible ships hundreds of idempotent modules that understand the state they manage. Reach for them before dropping to shell, command, or raw. Those three run arbitrary commands, are not idempotent, and, most importantly, are the primary vector for command injection when they interpolate unvalidated variables:
# Risky: an attacker-controlled username can inject commands
- name: Add user (unsafe)
ansible.builtin.shell: "useradd {{ username }}"
# Safer: the module validates and handles the value
- name: Add user (safe)
ansible.builtin.user:
name: "{{ username }}"
state: present
If a variable comes from an external source (an API, user input, a survey), treat it as untrusted. When you genuinely must use shell or command, validate inputs, use quote filters, and avoid building command strings by concatenation. Prefer command over shell where possible, since command does not run through a shell and therefore does not interpret pipes, redirects, and other metacharacters that widen the injection surface. Modules give you idempotence, better error handling, and a much smaller injection surface for free.
Template (Jinja2) injection
Ansible renders variables and templates through Jinja2, and templating untrusted input can lead to server-side template injection, where crafted values execute expressions rather than being treated as data. Never render values you do not control directly into templates or command strings. Keep externally supplied data as data: pass it as module parameters, apply the quote filter where relevant, and be cautious with constructs that evaluate strings. Review template: tasks that pull in variables originating from outside your controlled inventory.
SSH and connection security
Ansible reaches hosts primarily over SSH, so connection hygiene is core to Ansible security.
Manage keys properly. Use dedicated SSH keys for automation, protect private keys, and prefer an SSH agent or a short-lived certificate authority over long-lived keys scattered across machines.
Do not disable host key checking. It is tempting to set host_key_checking = False to silence prompts, but that removes protection against man-in-the-middle attacks. Instead, manage known_hosts so new hosts are trusted deliberately.
Prefer key-based auth over passwords, and avoid embedding connection passwords in inventory.
Supply chain: pin and vet collections and roles
Modern Ansible pulls collections and roles from Galaxy and other sources. Each one is code that runs in your environment, so treat it as part of your software supply chain. Unpinned dependencies mean a compromised or changed upstream can silently alter what your automation does.
Pin exact versions in requirements.yml and vet where content comes from:
collections:
- name: community.postgresql
version: "3.4.0"
roles:
- src: https://github.com/example/hardening-role
version: "v2.1.0"
Install from that manifest (ansible-galaxy install -r requirements.yml), review updates before bumping versions, and favor well-maintained, reputable sources. Rebuilding from a pinned manifest also makes your runs reproducible, and it gives you a single place to audit exactly what third-party code is executing in your environment. When you do update a dependency, read the changelog and diff the change the same way you would review application code, because a role that manages system state can do anything the account running it can do.
Idempotence and drift
Idempotence is a security property, not just a correctness one. A playbook that produces the same result no matter how many times it runs lets you re-apply your desired state to correct configuration drift. When automation is the authoritative definition of a system, unexpected changes stand out and manual tampering gets overwritten on the next run. Favor modules and declarative state (state: present, state: absent) over imperative shell steps so your playbooks converge reliably.
Linting and scanning playbooks in CI
The most reliable way to keep secure Ansible playbooks secure is to check them automatically on every change. Two layers work together.
ansible-lint catches risky patterns and anti-patterns: use of command where a module exists, missing no_log, deprecated syntax, and more. Run it in CI so problems fail the pipeline early:
# In your CI pipeline
- ansible-lint playbooks/
Alongside linting, run an IaC security scan that understands misconfigurations and exposed secrets across your automation and the infrastructure it defines. Linting enforces good style and known anti-patterns; security scanning looks for the risky outcomes, such as leaked credentials, over-permissive escalation, and insecure defaults, and grades them by severity. Together they turn security from a manual review step into an automated gate.
If you are securing IaC beyond Ansible, the same CI-first approach applies to your other tools, covered in our Terraform security and Kubernetes security deep dives.
How Rainforest helps
Rainforest brings Ansible into the same security workflow as the rest of your codebase. Our IaC security scanning analyzes your automation and infrastructure definitions for misconfigurations, exposed secrets, and insecure defaults, then surfaces findings with the context and severity your team needs to act. Because it plugs into the pipeline as part of the broader application security testing platform, the same scan runs on every merge request, so risky playbooks are flagged before they ship, not after an incident.
The result is automation you can trust: encrypted secrets, least-privilege escalation, safe modules, and a CI gate that keeps every change honest. Ready to see it on your own playbooks? Book a demo and we will walk through it with your stack.