How Linux Stores Passwords: The Shadow File Format

The shadow file replaced world-readable /etc/passwd hashes in the early 1990s. Today /etc/shadow is readable only by root and, on systemd-based distros, by the shadow group. Each line holds nine colon-delimited fields. The second field is the password hash, and the format of that hash tells you everything about the algorithm in use.

A hash starting with `$6$` is SHA-512 crypt. `$5$` is SHA-256. `$y$` is yescrypt, which is the default on Ubuntu 22.04+ and Fedora 35+. `$2b$` is bcrypt, available but not default on most distros. `$1$` is MD5 crypt - if you see this on a production machine, treat it as an incident.

To inspect what algorithm a running system uses for new passwords, check /etc/login.defs and search for ENCRYPT_METHOD. On RHEL 9 this is SHA512 by default. Ubuntu 22.04 and later default to yescrypt via libxcrypt. The difference matters: yescrypt is memory-hard, which increases the cost of GPU-based cracking by several orders of magnitude compared to SHA-512.

# Check the hash prefix on an existing account
sudo getent shadow youruser | cut -d: -f2 | cut -c1-4

# Check system default encryption method
grep ENCRYPT_METHOD /etc/login.defs

# View full shadow entry fields (run as root)
awk -F: '{print $1, $2, $3, $4, $5, $6, $7, $8, $9}' /etc/shadow | column -t | head -5

Setting the Password Hashing Algorithm

To switch a RHEL/CentOS 9 system to yescrypt, edit /etc/login.defs and set ENCRYPT_METHOD to YESCRYPT. Then set the YESCRYPT_COST_FACTOR - values range from 1 to 11, with 5 being the upstream default. Higher values are slower to compute, which is the point. On a modern server, cost factor 7 adds roughly 300ms of delay per hash - acceptable for interactive login, prohibitive for cracking at scale.

On Ubuntu 24.04, yescrypt is already the default. Verify by creating a test account and inspecting the shadow entry. If you are migrating from an old SHA-512 system, existing hashes are not automatically rehashed. Users must change their passwords before the new algorithm applies to their accounts. You can force this with `chage -d 0 username`, which expires the password immediately and prompts for a new one on next login.

Do not change the hashing algorithm on a system where users authenticate via LDAP or SSSD against a central directory. The shadow file is not authoritative in that case, and the setting in login.defs will have no effect on those accounts.

# /etc/login.defs - set on RHEL/Rocky/Alma 9
ENCRYPT_METHOD YESCRYPT
YESCRYPT_COST_FACTOR 7

# Force password expiry to trigger rehash on next login
sudo chage -d 0 targetuser

# Verify new hash prefix after user changes password
sudo getent shadow targetuser | cut -d: -f2 | head -c 4

PAM Architecture: What You Actually Need to Know

PAM (Pluggable Authentication Modules) intercepts authentication calls from applications like `su`, `sudo`, `sshd`, and `login`. Each service has its own PAM configuration file under /etc/pam.d/. The four PAM types are auth, account, password, and session. For password policy, you mostly care about `password` type entries and `account` type entries.

The module stack is processed top to bottom. Each line specifies a control flag: `required`, `requisite`, `sufficient`, or `optional`. The difference between `required` and `requisite` is critical: `required` always continues processing the stack and returns failure at the end, while `requisite` fails immediately and returns control to the application. Use `requisite` for checks that should abort early, like account expiry.

On RHEL-family systems, PAM config is layered through /etc/pam.d/system-auth and /etc/pam.d/password-auth, which are symlinks managed by authselect. Do not edit these files directly on RHEL 9 - use `authselect` to apply profiles and options. On Debian/Ubuntu, the equivalent is /etc/pam.d/common-password and pam-auth-update.

Before touching any PAM file, open a second root session and keep it open. A misconfigured PAM stack can lock you out of your own system, and the open session bypasses the broken auth chain.

# List current PAM config for the passwd command
cat /etc/pam.d/passwd

# On RHEL 9 - list available authselect profiles
authselect list

# Apply a profile with password history enabled
authselect select sssd with-pwhistory --force

# Verify active profile
authselect current
// advertisement

Enforcing Password Complexity with pam_pwquality

pam_pwquality replaced the older pam_cracklib on most modern distributions. It is the module that enforces minimum length, character class requirements, dictionary checks, and similarity checks against the old password. Configuration lives in /etc/security/pwquality.conf, and you can also pass options inline in the PAM stack.

In our testing on Rocky Linux 9.3, the default pwquality configuration requires a minimum score of 50 but does not enforce a minimum character class count. That means a dictionary word with one uppercase letter may pass. For a hardened baseline, set minlen to 14, minclass to 3, dcredit and ucredit to -1 each (negative values mean required count, not bonus score), and maxrepeat to 3. The `dictcheck` option controls whether the password is checked against cracklib dictionaries - leave it at 1.

The `retry` parameter in the PAM line controls how many times a user can attempt a new password before the module fails. Setting it to 3 is standard. The `enforce_for_root` option is off by default, which means root can set any password. On a shared server this is the correct behavior - you do not want a password change script to fail because it is running as root. On a single-admin server, enabling it is reasonable.

For teams managing service account passwords programmatically, consider integrating with a CLI-accessible credential store. 1Password's CLI (`op`) supports reading and writing credentials from shell scripts and CI pipelines, which keeps service account passwords out of plaintext config files while still allowing automated rotation. See https://1password.com for their Linux CLI documentation.

# /etc/security/pwquality.conf - hardened baseline
minlen = 14
minclass = 3
dcredit = -1
ucredit = -1
ocredit = -1
lcredit = -1
maxrepeat = 3
maxsequence = 4
dictcheck = 1
enforce_for_root = 0
retry = 3

# /etc/pam.d/common-password (Debian/Ubuntu) - relevant line
password requisite pam_pwquality.so retry=3

# Test a password against your current policy without changing anything
echo 'TestPassword123!' | pwscore

Password Aging and Account Expiry via chage and login.defs

Password aging is configured in two places: /etc/login.defs sets the defaults for new accounts, and `chage` controls individual accounts. The shadow file stores aging data in fields three through eight: last change date (days since epoch), minimum days before change, maximum days before forced change, warning period, inactivity period, and account expiry date.

For a standard production server, set PASS_MAX_DAYS to 90, PASS_MIN_DAYS to 1 (prevents immediate re-use cycling), PASS_WARN_AGE to 14, and INACTIVE to 30 (disables account 30 days after password expires with no login). These values only apply to new accounts. Existing accounts retain whatever is in their shadow entries.

To audit all accounts on a system and identify those with no expiry, run `chage -l` in a loop. Accounts with a maximum password age of 99999 (the default no-expiry sentinel) are effectively exempt from rotation policy. System accounts like `daemon`, `nobody`, and `www-data` should be locked, not expired - use `passwd -l accountname` to lock them, which prepends an exclamation mark to the hash in /etc/shadow.

For service accounts that automated tools use, rotating passwords on a schedule and injecting them into a secrets manager is a better pattern than aging. If you are running a DevOps automation platform, tools like those at taskbotshub.ai can orchestrate credential rotation as part of a broader secrets lifecycle pipeline.

# /etc/login.defs - aging defaults for new accounts
PASS_MAX_DAYS   90
PASS_MIN_DAYS   1
PASS_WARN_AGE   14
INACTIVE        30

# Apply these aging settings to an existing account
sudo chage -M 90 -m 1 -W 14 -I 30 username

# View current aging settings
sudo chage -l username

# Find all accounts with no password expiry (except locked accounts)
sudo awk -F: '($2 != "!" && $2 != "*" && $5 == 99999) {print $1}' /etc/shadow

# Lock a service account
sudo passwd -l serviceaccount

# Verify lock (hash will start with !)
sudo getent shadow serviceaccount | cut -d: -f2 | head -c 2

Account Lockout with pam_faillock

pam_faillock replaced pam_tally2 starting with RHEL 8 and is available on Debian/Ubuntu since libpam-modules 1.4.0. It tracks failed authentication attempts and locks accounts after a threshold is reached. Configuration is in /etc/security/faillock.conf on systems that support it, or passed inline as module arguments.

A reasonable policy: deny after 5 failures, unlock after 900 seconds (15 minutes), apply the lockout even to root accounts. The `audit` option logs the username of failed attempts to syslog, which is useful for detecting credential stuffing. The `silent` option suppresses error messages to the user about remaining attempts - turn this on if you do not want to help attackers know how close they are to lockout.

On RHEL 9, enabling pam_faillock through authselect is the supported path. Run `authselect enable-feature with-faillock`. This inserts the correct pam_faillock lines into the auth stack automatically. Do not manually edit /etc/pam.d/system-auth on RHEL 9 unless you understand that authselect will overwrite your changes.

On Ubuntu 24.04, you add pam_faillock to /etc/pam.d/common-auth manually. The module needs two entries: one before `pam_unix.so` with the `preauth` argument, and one after with `authfail`. The order matters - getting it wrong will either fail to count attempts or fail to block the user.

To manually unlock an account after a lockout, use `faillock --user username --reset`. To view current failure counts without resetting, run `faillock --user username`.

# /etc/security/faillock.conf
deny = 5
fail_interval = 900
unlock_time = 900
audit
even_deny_root
root_unlock_time = 60
silent

# RHEL 9 - enable via authselect
sudo authselect enable-feature with-faillock

# Ubuntu 24.04 - /etc/pam.d/common-auth additions
# Add BEFORE pam_unix.so line:
# auth required pam_faillock.so preauth
# Add AFTER pam_unix.so line:
# auth [default=die] pam_faillock.so authfail

# Check lockout status for a user
faillock --user targetuser

# Reset a locked account
sudo faillock --user targetuser --reset
// advertisement

Preventing Password Reuse with pam_pwhistory

pam_pwhistory stores previous password hashes in /etc/security/opasswd and rejects new passwords that match any stored hash. The `remember` parameter controls how many previous passwords are checked. A value of 12 covers one year of quarterly rotation or three months of monthly rotation.

On RHEL 9, enable this through authselect with `authselect select sssd with-pwhistory`. The profile inserts the pam_pwhistory line in the correct position in the password stack. On Ubuntu, add it manually to /etc/pam.d/common-password before the pam_unix.so line.

The opasswd file itself must be protected. It should be owned by root with mode 600. Check this periodically - some poorly written provisioning scripts reset ownership on /etc/security files. The `use_authtok` option on pam_pwhistory tells it to use the password already captured by the previous module in the stack rather than prompting again, which is the correct behavior when pam_pwquality runs first.

Note that pam_pwhistory only prevents reuse if the user changes their password through `passwd` or a PAM-aware application. A root user setting a password directly via `usermod -p` or by writing to /etc/shadow bypasses PAM entirely. If you are managing passwords programmatically, enforce history checks in your automation layer.

# /etc/pam.d/common-password (Ubuntu) - add before pam_unix.so
password required pam_pwhistory.so remember=12 use_authtok enforce_for_root

# Verify the opasswd file has correct permissions
ls -l /etc/security/opasswd
# Should show: -rw------- 1 root root

# Fix permissions if wrong
sudo chmod 600 /etc/security/opasswd
sudo chown root:root /etc/security/opasswd

# RHEL 9 - enable via authselect
sudo authselect select sssd with-pwhistory --force
authselect current

Auditing Your Current Password Policy

After configuring PAM and login.defs, run a full audit to confirm the policy is actually in effect. The `libpwquality` package includes `pwscore`, which evaluates a password against the current policy and returns a score from 0 to 100. Use this to verify your pwquality.conf settings are being picked up.

For a system-wide audit of aging settings, iterate through non-system accounts and report their chage values. In our experience, the fastest approach is a short awk script against /etc/shadow combined with `chage -l` for readable output. Pipe the results to a file and review any account where max days is 99999 or account expiry is set to never.

On RHEL 9, `authselect current` shows the active profile and any enabled features. Cross-reference this against your PAM files to confirm no manual edits have drifted from the authselect-managed configuration. The command `authselect check` specifically validates that the PAM files match what authselect expects and reports if they have been manually modified.

For SSH specifically, password authentication policy is enforced by sshd_config in parallel with PAM. Setting `PasswordAuthentication no` in /etc/ssh/sshd_config disables password logins over SSH regardless of PAM configuration. If you allow password-based SSH, confirm that `UsePAM yes` is set so that pam_faillock and pam_pwquality apply to SSH authentication attempts as well.

# Test a password against current pwquality policy
echo 'MyTestPassword1!' | pwscore

# Audit all non-system accounts for password aging
sudo awk -F: '$3 >= 1000 && $3 != 65534 {print $1}' /etc/passwd | \
  while read user; do
    echo "=== $user ==="
    sudo chage -l "$user"
  done

# RHEL 9 - check authselect integrity
authselect current
authselect check

# Verify sshd is using PAM
grep -E 'UsePAM|PasswordAuthentication' /etc/ssh/sshd_config

# Check for MD5 or SHA-256 hashes still in shadow (should be empty on hardened system)
sudo awk -F: '$2 ~ /^\$1\$/ || $2 ~ /^\$5\$/ {print $1, "WEAK HASH"}' /etc/shadow

Integrating CLI-Based Password Management for Teams

Local PAM policy handles system accounts, but engineering teams also need a way to store and share secrets - SSH keys, API tokens, database passwords - without resorting to a shared spreadsheet or a plaintext file on a jump host. A password manager with a proper CLI and Linux support removes this gap.

1Password's `op` CLI runs on Linux x86_64 and ARM64 and integrates with shell scripts, Ansible playbooks, and systemd service files through their secrets injection mechanism. You can reference a secret in a config file as `op://vault/item/field` and resolve it at runtime without storing the plaintext on disk. This is particularly useful for service account credentials that need to be rotated: update the value in 1Password, and any system pulling credentials via the `op` CLI gets the new value on the next execution. See https://1password.com for setup instructions on Linux.

For the PAM configuration side, the critical discipline is keeping system account passwords (the ones in /etc/shadow) separate from shared team secrets. System accounts that need password authentication should be managed through PAM policy as described above. Shared secrets used by applications and scripts belong in a secrets manager. Conflating the two leads to either overly permissive system account policies or application secrets ending up in /etc/shadow where they do not belong.

# Install 1Password CLI on Debian/Ubuntu
curl -sS https://downloads.1password.com/linux/keys/1password.asc | \
  sudo gpg --dearmor -o /usr/share/keyrings/1password-archive-keyring.gpg
echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] \
  https://downloads.1password.com/linux/debian/amd64 stable main' | \
  sudo tee /etc/apt/sources.list.d/1password.list
sudo apt update && sudo apt install 1password-cli

# Inject a secret into an environment variable
export DB_PASSWORD=$(op read 'op://Production/database/password')

# Use in a script without storing the plaintext
op run --env-file=.env -- ./deploy.sh
// advertisement