PAM policy: password quality and lockout

pwquality and faillock, tested safely.

Intermediate16 min · lesson 4 of 24

PAM (Pluggable Authentication Modules) is the library that console logins, su, sudo and SSH password logins ask whether to let someone in. In this lesson you read a PAM stack, add password-quality rules that follow current NIST guidance, lock an account after repeated failures, and apply both the way each distribution expects: pam-auth-update on Ubuntu, authselect on RHEL. Because one wrong line can lock every user out, including you, you also test each change on a separate service before any real login depends on it.

How a PAM stack decides

Each program that authenticates has a file in /etc/pam.d/, and on Ubuntu most of them include the same shared files. This is an untouched install:

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ grep -E "^@include|^auth|^account" /etc/pam.d/sshd /etc/pam.d/sudo
/etc/pam.d/sshd:@include common-auth /etc/pam.d/sshd:account required pam_nologin.so /etc/pam.d/sshd:@include common-account /etc/pam.d/sshd:@include common-session /etc/pam.d/sshd:@include common-password /etc/pam.d/sudo:@include common-auth /etc/pam.d/sudo:@include common-account /etc/pam.d/sudo:@include common-session-noninteractive
$ grep -Ev '^(#|$)' /etc/pam.d/common-auth
auth [success=1 default=ignore] pam_unix.so nullok auth requisite pam_deny.so auth required pam_permit.so auth optional pam_cap.so

sshd and sudo both include common-auth and common-account, so a change there applies to every login path at once, the console included.

Each line has a type, a control and a module. The type says which stage it belongs to: auth proves identity, account decides whether this account may log in now (expiry, lockout), password sets a new secret, and session sets up and tears down the login.

The control says what the module's answer means. requisite fails the whole stack at once. required fails it but keeps running the rest, so the caller cannot tell which module refused. optional rarely matters.

The bracket form maps results to actions. [success=1 default=ignore] on pam_unix means "if the password is right, skip one line", which jumps over pam_deny to pam_permit; any other result falls through to pam_deny. nullok lets an account with an empty password field in; the accounts lesson showed there are none, and RHEL's without-nullok feature removes the option.

On Ubuntu you do not edit these lines by hand. pam-auth-update builds the common-* files from profiles in /usr/share/pam-configs, one per module package, and a hand edit makes it treat the files as locally modified and stop updating them (pam-auth-update(8)).

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ ls /usr/share/pam-configs
capability mkhomedir systemd unix
$ dpkg -l libpam-pwquality
dpkg-query: no packages found matching libpam-pwquality

Four profiles ship by default, and neither the password-quality module nor a lockout profile is among them.

Test before you trust

The safe way to try a PAM stack is a separate service file that includes the shared stacks, exercised with pamtester (sudo apt install pamtester, from Ubuntu's universe repository) against a throwaway user. Real logins never read the test file, and pamtester prints the result of each PAM call directly. The test file isolates only the test, though: the moment you change a shared file, directly or with pam-auth-update, every login uses it. In the lab those changes landed in private copies of the PAM files in a separate mount namespace; on your machine they are live. The user:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd -m -s /bin/bash hard-pam-alice echo 'hard-pam-alice:Plum-Kettle-Orbit-42' | sudo chpasswd

And the service file:

/etc/pam.d/hard-pam-demo
# SecOpsLog lab: a separate PAM service for testing the shared stacks
# with pamtester. sshd, sudo and login never read this file.
@include common-auth
@include common-account
@include common-password
deploy@web01 · Ubuntu 26.04 LTS
$ echo 'Plum-Kettle-Orbit-42' | sudo pamtester hard-pam-demo hard-pam-alice authenticate
Password: pamtester: successfully authenticated
$ echo 'wrong' | sudo pamtester hard-pam-demo hard-pam-alice authenticate
Password: pamtester: Authentication failure

pamtester reads the password from standard input, so the right one authenticates and a wrong one returns "Authentication failure" with exit status 1. That is the baseline to compare every change against.

Password quality with pwquality

The threat is a password an attacker guesses from a list: short, common, or built from the user name. Ubuntu does not install the checking module by default (above). sudo apt install libpam-pwquality adds its profile, and pam-auth-update inserts the line in front of pam_unix (yescrypt on that line is the hash algorithm for new passwords); the lab machine already has it.

deploy@web01 · Ubuntu 26.04 LTS
$ grep pwquality /etc/pam.d/common-password cat /usr/share/pam-configs/pwquality
password requisite pam_pwquality.so retry=3 Name: Pwquality password strength checking Default: yes Priority: 1024 Conflicts: cracklib Password-Type: Primary Password: requisite pam_pwquality.so retry=3 Password-Initial: requisite pam_pwquality.so retry=3

The profile's Priority decides the order: higher numbers come first, and unix has 256. The rules themselves live in /etc/security/pwquality.conf or, better, a drop-in in /etc/security/pwquality.conf.d/. NIST SP 800-63B-4 asks for at least 15 characters for a password used on its own, a check against commonly used and compromised passwords, and no rules about character classes. The dictionary and user-name checks are on by default, so the drop-in needs two settings. As everywhere in this course, comments go on their own lines.

/etc/security/pwquality.conf.d/50-secopslog.conf
# SecOpsLog: length plus the dictionary and user-name checks (on by
# default), and no character-class rules (NIST SP 800-63B-4).
minlen = 15
# Apply the checks when root sets someone else's password too.
enforce_for_root
deploy@web01 · Ubuntu 26.04 LTS
$ printf '%s\n' 'Winter2026!' 'password12345678' 'hard-pam-alice-2026' | sudo pamtester hard-pam-demo hard-pam-alice chauthtok
New password: BAD PASSWORD: The password is shorter than 15 characters New password: BAD PASSWORD: The password fails the dictionary check - it is too simplistic/systematic New password: BAD PASSWORD: The password contains the user name in some form pamtester: Have exhausted maximum number of retries for service
$ printf '%s\n' 'lantern quietly orbits plum' 'lantern quietly orbits plum' | sudo pamtester hard-pam-demo hard-pam-alice chauthtok
New password: Retype new password: pamtester: authentication token altered successfully.

chauthtok is the password-change call. The three bad candidates fail the length check, cracklib's dictionary check (which calls password12345678 too simplistic) and the user-name check; after retry=3 attempts the change is refused. Without enforce_for_root, root would only see the warnings and the change would go through. A long passphrase passes. The dictionary is cracklib's word list, not a list of breached passwords; pwquality.conf(5) documents badwords for extra words and dictpath for a dictionary you build yourself.

Whose advice is this? The CIS RHEL 10 Level 1 profile in scap-security-guide 0.1.82 sets minlen 14 and minclass 4 (all four character types), a composition rule that NIST SP 800-63B-4 says verifiers "SHALL NOT impose". SecOpsLog follows NIST and records the deviation where a CIS audit applies. The impact is small: rules apply only when a password is set, existing passwords are not re-checked, and SSH key users are not affected. To roll back, delete the drop-in; sudo pam-auth-update --disable pwquality takes the module out of the stack.

Lockout with faillock

The threat is online guessing against whatever still accepts passwords: the console, su, sudo for users who need a password, SSH where password logins remain. NIST SP 800-63B-4 requires limiting consecutive failures to no more than 100; the CIS RHEL 10 Level 1 profile uses 5 failures and a 900-second lock. Settings go in /etc/security/faillock.conf. Its defaults (3 failures, 600 seconds) apply when, as on a fresh install, every line is commented out.

/etc/security/faillock.conf (lines added at the end)
# SecOpsLog: lock after 5 failures within 15 minutes; unlock after 15.
deny = 5
fail_interval = 900
unlock_time = 900

Ubuntu ships no faillock profile, so write two. pam_faillock(8) needs a preauth call before pam_unix and an authfail call after it, plus an account call that clears the count after a successful login. The priorities place them: 1024 runs before unix, 0 after it.

/usr/share/pam-configs/faillock
Name: Lock an account after repeated failures (pam_faillock, record)
Default: no
Priority: 0
Auth-Type: Primary
Auth:
[default=die] pam_faillock.so authfail
/usr/share/pam-configs/faillock_notify
Name: Lock an account after repeated failures (pam_faillock, check and reset)
Default: no
Priority: 1024
Auth-Type: Primary
Auth:
requisite pam_faillock.so preauth
Account-Type: Primary
Account:
required pam_faillock.so
deploy@web01 · Ubuntu 26.04 LTS
$ sudo pam-auth-update --enable faillock faillock_notify
…
# In a terminal this prints nothing; without one, debconf (the package configuration prompter) explains that it cannot open a dialog.
$ grep -Ev '^(#|$)' /etc/pam.d/common-auth grep -Ev '^(#|$)' /etc/pam.d/common-account
auth requisite pam_faillock.so preauth auth [success=2 default=ignore] pam_unix.so nullok try_first_pass auth [default=die] pam_faillock.so authfail auth requisite pam_deny.so auth required pam_permit.so auth optional pam_cap.so account required pam_faillock.so account [success=1 new_authtok_reqd=done default=ignore] pam_unix.so account requisite pam_deny.so account required pam_permit.so

pam-auth-update computed the jump: pam_unix now skips two lines on success, over the authfail call and pam_deny (try_first_pass makes it reuse a password an earlier module already read). Read the new stack as three paths:

One password attempt through the new common-auth
A password attempt enters common-auth
already locked
pam_faillock preauth refuses
requisite: fails at once, password never checked
password right
pam_unix jumps to pam_permit
success=2 skips authfail and pam_deny
password wrong
pam_faillock authfail records it
default=die: fails, counts toward deny = 5

Now fail five times, read the record, and try the right password:

deploy@web01 · Ubuntu 26.04 LTS
$ for i in 1 2 3 4 5; do echo 'wrong' | sudo pamtester hard-pam-demo hard-pam-alice authenticate; done
Password: pamtester: Permission denied Password: pamtester: Permission denied Password: pamtester: Permission denied Password: pamtester: Permission denied Password: pamtester: Permission denied
$ sudo faillock --user hard-pam-alice
hard-pam-alice: When Type Source Valid 2026-09-27 09:45:47 SVC hard-pam-demo V 2026-09-27 09:45:50 SVC hard-pam-demo V 2026-09-27 09:45:52 SVC hard-pam-demo V 2026-09-27 09:45:55 SVC hard-pam-demo V 2026-09-27 09:45:57 SVC hard-pam-demo V
$ echo 'lantern quietly orbits plum' | sudo pamtester hard-pam-demo hard-pam-alice authenticate
pamtester: Authentication failure The account is locked due to 5 failed logins. (15 minutes left to unlock)
$ sudo journalctl -t pamtester --since "-2 min" --no-hostname | grep faillock | tail -n 2
Sep 27 09:45:57 pamtester[261774]: pam_faillock(hard-pam-demo:auth): Consecutive login failures for user hard-pam-alice account temporarily locked

Five failures are recorded with the service that saw them (an SSH password failure would show the remote address instead). The sixth attempt, with the right password, is refused before the password is read. The journal line under pamtester is what you alert on; on a real server the identifier is the program that asked, such as sshd-session or login.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo faillock --user hard-pam-alice --reset echo "lantern quietly orbits plum" | sudo pamtester hard-pam-demo hard-pam-alice authenticate
Password: pamtester: successfully authenticated

Operational impact is where lockout policies go wrong. Anyone who knows an account name can lock it, so a shared account locked by a stranger is an outage. Root is exempt unless you add even_deny_root, and on Ubuntu root has no password anyway. Tallies live in /run/faillock, so a reboot clears them. SSH key logins never pass through the auth stage, so faillock neither counts nor blocks them. Your own mistyped sudo passwords do count, so an administrator who fumbles five times is locked out of sudo for 15 minutes. The lab's profile is written to show the lock; it also leaks. pam_faillock(8) notes that preauth without silent in faillock.conf, or under the requisite control, tells a guesser whether an account exists, because the lock message never appears for unknown names. RHEL's profile avoids both with auth required pam_faillock.so preauth silent (below); use that line in faillock_notify on a real server, at the cost of users not being told why they are refused. Clear a real user's lock with faillock --user NAME --reset only after checking where the failures came from. To roll back, disable the profiles:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo pam-auth-update --disable faillock faillock_notify
…
# In a terminal this prints nothing.
$ grep -Ev '^(#|$)' /etc/pam.d/common-auth
auth [success=1 default=ignore] pam_unix.so nullok auth requisite pam_deny.so auth required pam_permit.so auth optional pam_cap.so

The stack is back to Ubuntu's four lines. Delete the two profile files as well if you do not intend to enable them again.

pam-auth-update changes every login at once
On a real server, open a second session with sudo -i before you enable or disable a profile, and keep it open. After the change, open a new session and run sudo -k; sudo true to prove that sudo still works, and know sudo faillock --user NAME --reset, to run from the root shell. The console is no fallback: login includes the same common-auth, and on Ubuntu, where root's password is locked, the emergency shell does not open (sulogin(8) needs --force for that). What remains is a boot-level repair or a rescue image. For hosts whose PAM is managed centrally, keep a documented break-glass path, such as a console-only root password held in your secrets vault.

On RHEL 10: authselect

deploy@rocky10 · Rocky Linux 10.2
$ authselect current
Profile ID: local Enabled features: None
$ authselect list-features local | grep -E 'faillock|pwhistory|nullok'
with-faillock with-pwhistory without-nullok
$ grep -E 'pwquality|faillock|pam_unix' /etc/pam.d/system-auth
auth sufficient pam_unix.so nullok account required pam_unix.so password requisite pam_pwquality.so password sufficient pam_unix.so yescrypt shadow nullok use_authtok session required pam_unix.so
$ ls -l /etc/pam.d/system-auth /etc/pam.d/password-auth
lrwxrwxrwx. 1 root root 29 Sep 27 05:12 /etc/pam.d/password-auth -> /etc/authselect/password-auth lrwxrwxrwx. 1 root root 27 Sep 27 05:12 /etc/pam.d/system-auth -> /etc/authselect/system-auth

RHEL builds system-auth and password-auth from an authselect profile, and the files in /etc/pam.d are symbolic links into /etc/authselect, so a hand edit is lost the next time authselect writes them. pam_pwquality is already in the stack, with library defaults (a minimum length of 8), so the same drop-in as above applies. Lockout is a feature of the profile:

deploy@rocky10 · Rocky Linux 10.2
$ grep -Ev '^(#|$)' /etc/security/faillock.conf /etc/security/pwquality.conf | wc -l
0
$ sudo authselect enable-feature with-faillock authselect current
Profile ID: local Enabled features: - with-faillock
$ grep faillock /etc/pam.d/system-auth
auth required pam_faillock.so preauth silent auth required pam_faillock.so authfail account required pam_faillock.so
$ sudo authselect disable-feature with-faillock grep -c faillock /etc/pam.d/system-auth
0

with-faillock adds the same three calls, but as required and with silent on preauth, so neither the control nor a message reveals whether an account exists; it reads /etc/security/faillock.conf, which on this machine is all comments (defaults: 3 failures, 600 seconds). disable-feature is the rollback, and without-nullok and with-pwhistory are the other features a hardening baseline usually asks for. pamtester is not in the Rocky Linux BaseOS, AppStream or Extras repositories, so on RHEL test with a throwaway user at the console or over a password-enabled SSH test instance.

Try this

On an Ubuntu test machine, with a second session open as root (sudo -i), create a throwaway user with a password, the hard-pam-demo service file and the two faillock profiles, and run sudo pam-auth-update --enable faillock faillock_notify. Fail five times with pamtester and confirm that the right password then gets "The account is locked due to 5 failed logins." Check sudo faillock --user, reset it, and confirm the right password works again. Add the pwquality drop-in and confirm that a 14-character password is refused with "shorter than 15 characters". Finish with sudo pam-auth-update --disable faillock faillock_notify and check that common-auth has four lines again.

Takeaway

Change PAM only through pam-auth-update profiles or authselect features, test the result with pamtester on a separate service before real logins use it, and keep a root session (sudo -i) open until you have. Prefer length and a blocklist over character rules, and a temporary lock over a permanent one.

Quick check
01A teammate hand-edits /etc/pam.d/common-auth on Ubuntu to add pam_faillock lines. What problem does that create later?
Correct — pam-auth-update(8) stops managing locally modified files unless run with --force, which saves the edit as .pam-old and rewrites the file.
Incorrect — common-auth is generated by pam-auth-update, not shipped as a file to replace. The risk is that it is no longer updated.
Incorrect — PAM reads whatever is in the file. The hand-added lines do run; the trouble is future changes.
Incorrect — sshd does not validate PAM files against profiles; it only uses the stack at login time.
02Your CIS-based standard requires minclass = 4 (all four character types) for passwords. How would NIST SP 800-63B-4 guidance change that rule?
Incorrect — NIST says verifiers shall not require periodic changes, so this adds a second rule it prohibits.
Incorrect — NIST says the opposite: verifiers shall not impose composition rules.
Correct — Length and a check against common or compromised passwords replace composition rules.
Incorrect — That is another composition-style rule, and it would reject many good passphrases.
03faillock (deny = 5, unlock_time = 900) is enabled on a host where SSH still accepts passwords. Someone on the internet keeps trying passwords for the shared account "backup". What happens?
Incorrect — faillock keeps counts per account, not per address. It has no way to block a source.
Correct — Each burst of failures re-locks the account for 15 minutes. Remove password SSH or the shared account instead of relying on lockout alone.
Incorrect — SSH password logins go through the same common-auth stack, so their failures are counted.
Incorrect — Key logins skip the auth stage; pam_faillock only resets after a full authentication (pam_faillock(8) notes).

Related