Introduction

Changing user passwords across multiple Linux servers is a common administrative task that Ansible automates securely. The ansible.builtin.user module requires passwords in hashed form (not plaintext), which Ansible's password_hash filter handles automatically. This article covers basic password changes, Vault-encrypted passwords, bulk updates, password policies, and idempotent management.

Basic Password Change

---
- name: Change user password
  hosts: all
  become: true
  vars:
    myuser: "example"
    mypassword: "SecureP@ss2026!"
  tasks:
    - name: Update password
      ansible.builtin.user:
        name: "{{ myuser }}"
        state: present
        password: "{{ mypassword | password_hash('sha512') }}"

Execution

$ ansible-playbook -i inventory change_password.yml
PLAY [Change user password] *******************
TASK [Update password] ************************
changed: [demo.example.com]
PLAY RECAP ************************************
demo.example.com : ok=2 changed=1 unreachable=0 failed=0

Verify

$ sshpass -p 'SecureP@ss2026!' ssh example@demo.example.com whoami
example

Understanding password_hash

Linux stores passwords as hashes in /etc/shadow. Ansible requires pre-hashed passwords — the password_hash filter generates them:

# SHA-512 (recommended — default on most Linux distros)
password: "{{ 'mypassword' | password_hash('sha512') }}"

# SHA-256
password: "{{ 'mypassword' | password_hash('sha256') }}"

# With explicit salt (for idempotency)
password: "{{ 'mypassword' | password_hash('sha512', 'mysalt') }}"

# With rounds parameter
password: "{{ 'mypassword' | password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) }}"

The Idempotency Problem

Without a fixed salt, password_hash generates a random salt each run, causing changed every time:

# ❌ Changes every run (random salt)
password: "{{ mypassword | password_hash('sha512') }}"

# ✅ Idempotent (fixed salt per host)
password: "{{ mypassword | password_hash('sha512', inventory_hostname_short) }}"

Or use update_password: on_create to only set the password when creating the user:

- name: Set password only on creation
  ansible.builtin.user:
    name: deploy
    password: "{{ mypassword | password_hash('sha512') }}"
    update_password: on_create

Secure Passwords with Ansible Vault

Never store plaintext passwords in playbooks. Use Ansible Vault:

Create Encrypted Variable

# Encrypt a password string
ansible-vault encrypt_string 'SecureP@ss2026!' --name 'user_password'

# Output:
user_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  3832653363...

Use in Playbook

---
- name: Change password (vault-encrypted)
  hosts: all
  become: true
  vars:
    user_password: !vault |
      $ANSIBLE_VAULT;1.1;AES256
      3832653363616437...
  tasks:
    - name: Update password
      ansible.builtin.user:
        name: deploy
        password: "{{ user_password | password_hash('sha512', inventory_hostname_short) }}"
      no_log: true

Use Vault File

# vars/passwords.yml (encrypted with ansible-vault encrypt)
deploy_password: "SecureP@ss2026!"
admin_password: "Adm1n#P@ss!"
---
- name: Change passwords from vault
  hosts: all
  become: true
  vars_files:
    - vars/passwords.yml
  tasks:
    - name: Update deploy user
      ansible.builtin.user:
        name: deploy
        password: "{{ deploy_password | password_hash('sha512', inventory_hostname_short) }}"
      no_log: true
# Run with vault password
ansible-playbook site.yml --ask-vault-pass

Bulk Password Changes

Multiple Users from a List

---
- name: Update passwords for multiple users
  hosts: all
  become: true
  vars:
    users:
      - name: deploy
        password: "{{ vault_deploy_pass }}"
      - name: admin
        password: "{{ vault_admin_pass }}"
      - name: monitoring
        password: "{{ vault_monitoring_pass }}"
  tasks:
    - name: Update all user passwords
      ansible.builtin.user:
        name: "{{ item.name }}"
        password: "{{ item.password | password_hash('sha512', inventory_hostname_short) }}"
      loop: "{{ users }}"
      no_log: true

Random Password Generation

---
- name: Generate and set random passwords
  hosts: all
  become: true
  tasks:
    - name: Generate random password
      ansible.builtin.set_fact:
        new_password: "{{ lookup('password', '/dev/null length=20 chars=ascii_letters,digits,punctuation') }}"
      no_log: true

    - name: Set password
      ansible.builtin.user:
        name: deploy
        password: "{{ new_password | password_hash('sha512') }}"
      no_log: true

    - name: Save password locally
      ansible.builtin.copy:
        content: "{{ inventory_hostname }}: {{ new_password }}"
        dest: "/tmp/passwords/{{ inventory_hostname }}.txt"
        mode: "0600"
      delegate_to: localhost
      no_log: true

Password Expiry and Policies

Force Password Change on Next Login

- name: Create user with expired password
  ansible.builtin.user:
    name: newuser
    password: "{{ temp_password | password_hash('sha512') }}"
    password_expire_max: 90
    password_expire_min: 1

- name: Force password change on next login
  ansible.builtin.command: chage -d 0 newuser
  changed_when: true

Set Password Expiry

- name: Set password to expire in 90 days
  ansible.builtin.user:
    name: deploy
    password_expire_max: 90
    password_expire_min: 7

# Or use chage for more control
- name: Configure password aging
  ansible.builtin.command: >
    chage --maxdays 90 --mindays 7 --warndays 14 deploy
  changed_when: true

Lock and Unlock Accounts

# Lock account (disable login)
- name: Lock user account
  ansible.builtin.user:
    name: old_employee
    password_lock: true

# Unlock account
- name: Unlock user account
  ansible.builtin.user:
    name: returning_employee
    password_lock: false

Platform Differences

PlatformHash FormatNotes
Linux (RHEL, Ubuntu, SUSE)SHA-512 via password_hash('sha512')Standard
macOSCleartext acceptedUses dscl internally
FreeBSDSHA-512Same as Linux
WindowsUse ansible.windows.win_userAccepts cleartext

Windows Password Change

- name: Change Windows user password
  ansible.windows.win_user:
    name: Administrator
    password: "{{ vault_win_password }}"
    state: present

Common Mistakes

1. Plaintext Password (No Hash)

# ❌ WRONG — stores literal string in /etc/shadow
password: "mypassword"

# ✅ RIGHT — hashes the password
password: "{{ 'mypassword' | password_hash('sha512') }}"

2. Missing no_log

# ❌ Password visible in ansible output
- ansible.builtin.user:
    name: deploy
    password: "{{ pass | password_hash('sha512') }}"

# ✅ Hidden from output
- ansible.builtin.user:
    name: deploy
    password: "{{ pass | password_hash('sha512') }}"
  no_log: true

3. Missing become

# ❌ Fails — can't modify /etc/shadow without root
- ansible.builtin.user:
    name: deploy
    password: "{{ pass | password_hash('sha512') }}"

# ✅ Needs privilege escalation
- ansible.builtin.user:
    name: deploy
    password: "{{ pass | password_hash('sha512') }}"
  become: true

Complete Production Playbook

---
- name: Rotate service account passwords
  hosts: all
  become: true
  vars_files:
    - vars/passwords.yml  # vault-encrypted
  vars:
    service_accounts:
      - name: deploy
        password: "{{ vault_deploy_pass }}"
        groups: ['sudo']
        shell: /bin/bash
      - name: monitoring
        password: "{{ vault_monitoring_pass }}"
        groups: ['monitoring']
        shell: /usr/sbin/nologin
  tasks:
    - name: Update service account passwords
      ansible.builtin.user:
        name: "{{ item.name }}"
        password: "{{ item.password | password_hash('sha512', inventory_hostname_short) }}"
        groups: "{{ item.groups }}"
        shell: "{{ item.shell }}"
        update_password: always
      loop: "{{ service_accounts }}"
      no_log: true

    - name: Verify accounts exist
      ansible.builtin.command: "id {{ item.name }}"
      loop: "{{ service_accounts }}"
      changed_when: false

Conclusion

Change Linux user passwords with Ansible using ansible.builtin.user and the password_hash('sha512') filter. Always use Ansible Vault for password storage, no_log: true to hide passwords from output, and update_password: on_create for idempotent initial setup. For consistent behavior across runs, provide a fixed salt to password_hash based on the hostname.