Setting Up Password Policies with libpwquality on CentOS Stream 9: Enterprise Security in Practice

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

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 to minlen (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.

Share: