After the CentOS 8 EOL situation in 2021, I had to migrate 5 servers to Rocky Linux in exactly one week. Incredibly stressful, but also a rare opportunity to audit the entire security configuration — and I discovered that the password policies on most of those servers were at… basically default settings. No complexity enforcement, no reuse limits, nothing at all. That’s when I started seriously looking into libpwquality.
This article is based on real-world deployment experience on CentOS Stream 9 — a production environment with ~30 user accounts, mixing LDAP and local accounts. No textbook theory here.
3 Approaches to Enforcing Password Policies on Linux
I considered 3 approaches before making a decision — each one had its own reasons to consider:
1. pam_cracklib (legacy)
An older PAM module, still present on many distros. Used to be the standard on RHEL 6/7. Still functional — but RHEL 8+ has deprecated it. On CentOS Stream 9, this package no longer exists in the default repo.
2. libpwquality + pam_pwquality (current recommendation)
The official replacement for pam_cracklib starting from RHEL 7. Same developer (Tomáš Mráz), backward-compatible API, but more options, better integration with the pwscore CLI, and actively maintained. On CentOS Stream 9, this is the default.
3. Custom PAM script + pam_exec
Write your own shell script, invoked via pam_exec. Most flexible — but a maintenance nightmare. I once saw this kind of setup at a client: nearly 200 lines of bash, no comments, and nobody dared touch it. Hard pass.
Real-World Comparison: pam_cracklib vs pam_pwquality
| Criteria | pam_cracklib | pam_pwquality |
|---|---|---|
| Status on RHEL 9 | Not in repo | Pre-installed, default |
| Dedicated config file | No (PAM args only) | Yes (/etc/security/pwquality.conf) |
| pwscore CLI tool | No | Yes |
| Dictionary check | cracklib dict | cracklib dict (improved) |
| Retry attempts | Yes | Yes (configurable) |
Short answer: pam_pwquality, no debate. On CentOS Stream 9, pam_cracklib isn’t even in the default repo — there’s nothing left to compare.
Checking the Current Environment
Don’t assume it’s already installed. Check before doing anything:
# Check package
rpm -q libpwquality
# Output: libpwquality-1.4.4-8.el9.x86_64
# Check current PAM config
grep -n pwquality /etc/pam.d/system-auth
grep -n pwquality /etc/pam.d/password-auth
On a fresh CentOS Stream 9 installation, you’ll see output like:
password requisite pam_pwquality.so try_first_pass local_users_only
If it’s not present or needs reinstalling:
dnf install libpwquality -y
Configuring /etc/security/pwquality.conf
This is the central configuration file. I always back it up before editing — a habit formed after a production server got locked out due to a broken PAM config last year:
cp /etc/security/pwquality.conf /etc/security/pwquality.conf.bak
vim /etc/security/pwquality.conf
Here’s the config I use for a reasonably strict enterprise environment (not so strict that users revolt):
# /etc/security/pwquality.conf
# Minimum length
minlen = 12
# Minimum characters per type (negative value = required)
minclass = 3 # Must have at least 3 of 4 types: upper, lower, digit, other
dcredit = -1 # Require at least 1 digit
ucredit = -1 # Require at least 1 uppercase letter
lcredit = -1 # Require at least 1 lowercase letter
ocredit = 0 # Special characters: not required (users complain about these)
# Dictionary and pattern checks
dictcheck = 1 # Check against cracklib dictionary
usercheck = 1 # Disallow using username in password
enforcing = 1 # Fail hard if quality not met (not just a warning)
# Limit consecutive repeated characters
maxrepeat = 3 # No more than 3 consecutive repeats: "aaaa" rejected
maxsequence = 4 # No sequences: "1234", "abcd"
# Number of characters that must differ from old password
difok = 5
# Number of retries on failure
retry = 3
# Additional bad words (add per your context)
# badwords = company password admin root
Understanding credit logic
The dcredit, ucredit… settings are the most commonly misunderstood. Simple rules:
- A negative value (e.g.,
-1): requires at least 1 character of that type - A positive value (e.g.,
1): each character of that type adds credit tominlen(legacy cracklib behavior) - A value of 0: this rule is not applied
Use negative values — they’re much clearer and easier to audit.
Configuring PAM to Enforce the Policy
Important note: On CentOS Stream 9 with authselect, do NOT directly edit /etc/pam.d/system-auth or /etc/pam.d/password-auth. Those files will be overwritten.
# Check current profile
authselect current
# Typical output:
# Profile ID: sssd
# Enabled features: with-faillock
If you need to add options to pam_pwquality, use an authselect custom profile:
# Create a custom profile based on sssd
authselect create-profile custom-security --base-on sssd
# Edit the password-auth file in the new profile
vim /etc/authselect/custom/custom-security/password-auth
Find the pam_pwquality.so line and adjust it. Add enforce_for_root if you want the policy applied to root as well:
password requisite pam_pwquality.so try_first_pass local_users_only retry=3 enforce_for_root authtok_type=
Then apply the profile:
authselect select custom/custom-security with-faillock --force
Configuring Password Aging with chage
But libpwquality alone isn’t enough. It only checks password quality at the time of change — it doesn’t enforce periodic password rotation. You also need chage and /etc/login.defs:
# Default settings for new users in /etc/login.defs
grep -E 'PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_WARN_AGE' /etc/login.defs
# Edit /etc/login.defs
PASS_MAX_DAYS 90 # Force password change after 90 days
PASS_MIN_DAYS 1 # Must wait at least 1 day before changing again
PASS_WARN_AGE 14 # Warn 14 days before expiration
# Apply to existing users (example: user hieu)
chage -M 90 -m 1 -W 14 hieu
# View all aging settings for a user
chage -l hieu
Real-World Testing with pwscore
The biggest advantage of libpwquality over pam_cracklib: the pwscore tool. This CLI lets you test passwords immediately without creating a user or changing a real password — extremely handy when debugging policies or validating automation scripts:
# Test password strength (input via stdin)
echo "password123" | pwscore
# Output: 0 (very weak)
echo "P@ssw0rd" | pwscore
# Output: 50 (moderate)
echo "Xk9#mNqL2vBp" | pwscore
# Output: 100 (strong)
Scores range from 0 to 100. Passwords that fail the policy return an error with a specific reason:
echo "abc" | pwscore
# The password is shorter than 12 characters.
# Password quality check failed:
# The password is shorter than 12 characters.
I integrated pwscore into the onboarding script to validate passwords before creating accounts. Much more efficient than trying directly and finding out it was rejected after the fact.
Verifying the Full Configuration
The simplest real-world test: try changing a password and see how the system responds. Use a test user, not root:
# Test by changing a regular user's password (do NOT test with root)
passwd testuser
# Try a weak password: "simple"
# Expected: BAD PASSWORD: The password is shorter than 12 characters.
# Try a password without enough character classes: "alllowercasehere"
# Expected: BAD PASSWORD: The password contains less than 1 uppercase letters
# Try a valid password: "Secure#2024Linux"
# Expected: passwd: all authentication tokens updated successfully.
# Check logs during password change
tail -f /var/log/secure | grep passwd
Key Considerations for Production Deployment
Root and enforce_for_root: By default, root can still set weak passwords for other users with only a warning, not a block. To strictly enforce the policy for root as well, add enforce_for_root to the pam_pwquality.so line in your PAM config (see above). Note: root is not prompted for the old password, so old/new comparison checks don’t run — this is expected behavior, not a bug.
LDAP/AD users: libpwquality only enforces when changing local passwords through PAM. If users change passwords directly on the LDAP server, this policy has no effect. You’ll need to configure password policies on the LDAP side separately.
Script automation: When using chpasswd with plaintext passwords, PAM is invoked normally — pam_pwquality will check. Exception: chpasswd -e accepts pre-hashed passwords, so quality checking is completely bypassed. Know this so you’re not surprised during a future audit. To ensure enforcement, use passwd instead.
# Create multiple users with strong random passwords
for user in user1 user2 user3; do
useradd "$user"
# Generate a random 16-character password
pass=$(openssl rand -base64 16 | tr -d '=+/' | head -c 16)
echo "${user}:${pass}" | chpasswd
# Force password change on first login
chage -d 0 "$user"
echo "User: $user | Pass: $pass"
done
After 6 months running this config in production, I noticed a roughly 15% increase in “forgot my password” tickets during the first month (users complained the passwords were hard to remember), but it stabilized after that. An acceptable trade-off for reducing the risk of brute-force attacks and credential stuffing.

