CoursesLinux hardeningAccounts & PAM policy

Accounts & PAM policy

Password rules, lockout, and login limits.

Intermediate12 min · lesson 3 of 16

A big office building has a lot of doors. The front entrance, the parking garage, the server room, the loading dock. You do not want each door inventing its own way to check badges, because then a mistake at one door is a hole nobody else knows about. So the building runs one security desk with one rulebook, and every door phones that same desk before it opens. On Linux, the shared desk is PAM (Pluggable Authentication Modules), the machinery the operating system calls whenever a program needs to prove who you are.

When you log in at the console, run sudo (the command that runs one action as another user, usually root), open an SSH (Secure Shell, the encrypted remote-login protocol) session, or type your password to get back past a locked screen, none of those programs decides on its own whether to let you in. Each hands the question to PAM. That is the whole point. You set your password rules, your lockout policy, and your login limits once, in the place every login path already consults, instead of hoping every service got it right by itself.

How PAM Decides Who Gets In

Open /etc/pam.d/ and you see one file per service (sshd, sudo, login) plus a few shared fragments. Each line is one check, and the checks fall into four kinds of question. auth asks whether you are who you claim to be. account asks whether you are allowed in right now, today, from here. password governs how a new secret gets set. session runs the setup and teardown around a login, like mounting your home directory and applying limits. Ubuntu and Debian keep the shared logic in common-auth, common-account, common-password, and common-session, and a tool called pam-auth-update stitches them together so a package upgrade does not stomp your edits.

Each line also carries a control word (required, requisite, sufficient) that says what happens when that check passes or fails. A requisite failure stops the whole stack immediately. A required failure is recorded, the stack keeps running, then fails at the end so an attacker cannot tell which step rejected them. That ordering is why the same module gets listed more than once, as you will see with lockout.

Password Rules Worth Having

A four-digit bike lock is weak because it is short, not because the digits are exotic. Length is what makes guessing expensive. The file /etc/security/pwquality.conf is where you set the floor for every password on the box.

/etc/security/pwquality.conf
minlen = 14 # minimum length; length beats clever complexity
difok = 5 # a new password must differ from the old in 5+ positions
dcredit = -1 # require at least one digit
ucredit = -1 # require at least one uppercase letter
maxrepeat = 3 # no more than 3 identical characters in a row
enforce_for_root = 1 # root is not exempt

The credit options read backwards until you know the trick. A negative number is a requirement, a positive number is a bonus that counts toward the length. dcredit = -1 means the password must contain at least one digit (d is for digit). ucredit = -1 requires an uppercase letter. difok = 5 means a new password has to differ from the old one in at least five spots, so nobody rotates Summer2025 into Summer2026 and calls it a change. enforce_for_root = 1 makes the root account obey the same floor, which matters because root is the account attackers actually want.

None of that runs until the module is in the stack. On Debian and Ubuntu the rule lives in common-password, called through pam_pwquality.so.

/etc/pam.d/common-password
password requisite pam_pwquality.so retry=3
password [success=1 default=ignore] pam_unix.so obscure use_authtok try_first_pass yescrypt

That second line is the one that stores the result, hashing the password with yescrypt (a modern, deliberately slow password-hashing function that makes offline cracking painful). Test the whole thing the honest way, by trying to set a bad password as an ordinary user.

~/secopslog — bash
$ deploy@web01:~$ passwd
Changing password for deploy. Current password: New password: BAD PASSWORD: The password is shorter than 14 characters New password: BAD PASSWORD: The password fails the dictionary check - it is based on a dictionary word New password: BAD PASSWORD: The password contains less than 1 digits passwd: Have exhausted maximum number of retries for service passwd: password unchanged

Slowing Down a Brute-Force

A cash machine (ATM) eats your card after three wrong PINs. It does not care how fast you type. Three strikes and the card is dead for a while. The pam_faillock module does the same for accounts. After a set number of failures it refuses that account for a cooldown window, so an attacker spraying passwords gets a handful of tries an hour instead of thousands a second. The settings go in one file.

/etc/security/faillock.conf
deny = 5 # lock the account after 5 failed attempts
unlock_time = 900 # keep it locked for 15 minutes (900 seconds)
fail_interval = 900 # only count failures within a 15-minute window
even_deny_root # a bare flag: root is locked too

That last flag has a sharp edge. even_deny_root locks root as well, so a password-spray aimed at root can lock root out too, which is a denial of service (an outage caused by using up a resource, here the login itself). On a keys-only server that is usually the right call, but keep a way back in that you control, like physical console access or a break-glass account (an emergency admin login you set aside for exactly this), so a stranger cannot lock you out of your own machine by doing nothing but guessing wrong.

Like pwquality, faillock does nothing until it is wired into the auth stack, and it appears there more than once on purpose: a preauth check before the password is tested, an authfail line that records a loss, and an authsucc line on the winning path. It also needs a single line in the account group to actually turn a counted failure into a refusal.

/etc/pam.d/common-auth
auth requisite pam_faillock.so preauth
auth [success=1 default=ignore] pam_unix.so nullok
auth [default=die] pam_faillock.so authfail
auth sufficient pam_faillock.so authsucc
# and in /etc/pam.d/common-account:
account required pam_faillock.so

This is where operations meets security. When someone runs a password-spray against your SSH port, the misses pile up in the authentication log (/var/log/auth.log) and faillock starts counting. The pam_unix line names the source address, and the pam_faillock line tells you the account crossed the limit. Once the lock trips, later tries show only the faillock line, because the requisite preauth check turns them away before the password is ever tested.

~/secopslog — bash
$ sudo grep -E 'pam_(unix|faillock)' /var/log/auth.log | tail -3
Jul 17 09:14:31 web01 sshd[2043]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.44 user=deploy Jul 17 09:14:31 web01 sshd[2043]: pam_faillock(sshd:auth): Consecutive login failures for user deploy account temporarily locked Jul 17 09:14:36 web01 sshd[2051]: pam_faillock(sshd:auth): Consecutive login failures for user deploy account temporarily locked

To see where an account stands, ask faillock directly. Each row is a recorded failure with the source address, which is often the fastest way to spot the attacking host.

~/secopslog — bash
$ sudo faillock --user deploy
deploy: When Type Source Valid 2026-07-17 09:14:22 RHOST 203.0.113.44 V 2026-07-17 09:14:24 RHOST 203.0.113.44 V 2026-07-17 09:14:27 RHOST 203.0.113.44 V 2026-07-17 09:14:29 RHOST 203.0.113.44 V 2026-07-17 09:14:31 RHOST 203.0.113.44 V

Once you have confirmed a lockout was a stuck teammate and not the attacker still knocking, clear the counter with sudo faillock --user deploy --reset. Resetting while the source address is still hammering you only hands them another five free guesses, so check the source first.

Capping the Blast Radius

A fuse box keeps one shorted appliance from burning down the house. Resource limits do that for accounts. A fork bomb (a script that spawns processes until the machine falls over) is contained if the account it runs under can only ever hold a few hundred processes. You set the ceilings in /etc/security/limits.conf.

/etc/security/limits.conf
# domain type item value
@developers hard nproc 512 # max processes per developer
@developers hard nofile 4096 # max open files per developer
* hard nproc 1024 # a ceiling for everyone else

The item nproc caps the number of processes a user can run (nproc is number of processes). nofile caps open file handles. hard is the ceiling nobody can raise, soft is the everyday default a user could bump up toward the hard limit. The module pam_limits.so in the session group applies these caps at login. deploy is one of the developers, so read its hard ceiling back with ulimit (the shell's built-in for showing and setting resource limits), using -H for the hard value, to confirm it landed.

~/secopslog — bash
$ sudo su - deploy -c 'ulimit -Hu'
512

Stale Credentials and Idle Accounts

A spare key you cut years ago still opens the door if nobody rekeyed the lock. Old credentials are the same. The account nobody has logged into since a contractor rolled off is exactly what an attacker hopes is still valid months later. Two files handle aging: login.defs sets the defaults stamped onto new accounts, and chage adjusts an account that already exists.

~/secopslog — bash
$ sudo chage -l deploy
Last password change : Apr 12, 2026 Password expires : Apr 12, 2027 Password inactive : never Account expires : never Minimum number of days between password change : 0 Maximum number of days between password change : 365 Number of days of warning before password expires : 7

Those numbers live in /etc/shadow (one line per user, each date stored as a plain count of days since January 1, 1970). chage is the friendly front end that does the arithmetic for you. The account above carries only the distro default, 365 days with no inactivity lock. To tighten it, give chage a shorter maximum age and an inactivity window, and to retire an account for good, lock its password so no password login works at all. chage changes the account quietly and prints nothing on success, so the single line below is passwd confirming the lock.

~/secopslog — bash
$ sudo chage -M 90 -I 30 deploy # expire the password after 90 days; disable the login 30 days later sudo passwd -l olduser # disable password login for a departed user
passwd: password expiry information changed.
/etc/login.defs
PASS_MAX_DAYS 365 # default max password age for new accounts
PASS_MIN_DAYS 0 # no forced wait before a user can change again
PASS_WARN_AGE 7 # warn 7 days before expiry
ENCRYPT_METHOD YESCRYPT # hash new passwords with yescrypt
UMASK 022 # default permission mask for new files

Here is where good intentions go sideways. Forcing a password change every 30 days feels safe and does the opposite. NIST (the United States National Institute of Standards and Technology) found that frequent forced rotation pushes people toward weak, predictable patterns, Spring1, Spring2, Spring3, often on a sticky note under the keyboard. The stronger setup is a long unique password, checked against lists of known-breached passwords, plus MFA (multi-factor authentication, a second proof like a phone app or a hardware key) and lockout. Set a long maximum age to catch genuinely abandoned accounts, and let length and breach-checking carry the weight instead of the calendar.

A bad PAM line can lock out every login
A syntax error or a wrong control word in /etc/pam.d/ can deny console, SSH, and sudo all at once, with no way back in. Before you edit anything under /etc/pam.d, keep a second terminal already logged in as root, and test the change from a third session before you close the safe one. On Debian and Ubuntu, prefer pam-auth-update over hand-editing common-* files so an upgrade does not silently revert your work.
One login, four PAM phases, one control each
auth (prove identity)
pam_unix.so
checks the password against the yescrypt hash
pam_faillock.so
counts failures, blocks after deny=
account (allowed now?)
pam_faillock.so
enforces the active lockout
pam_unix.so
honors password and account expiry
password (set a new secret)
pam_pwquality.so
minlen, difok, digit and case rules
pam_unix.so
writes the new hash to /etc/shadow
session (setup / teardown)
pam_limits.so
applies nproc and nofile caps
A single login touches every phase; the hardening controls in this lesson each live in a different one.
Quick check
01You added deny = 5 to /etc/security/faillock.conf, but accounts still never lock after repeated bad SSH passwords. What is the most likely reason?
Correct — faillock.conf only holds settings; the module has to be present in the PAM stack to run and enforce them.
Incorrect — No. unlock_time controls how long a lock lasts, not whether locking happens at all.
Incorrect — No. Only the *credit password options use negatives; deny is a plain count.
Incorrect — No. The password-hashing algorithm has nothing to do with counting failures.
02In /etc/security/pwquality.conf you see 'dcredit = -1'. What does that setting do?
Correct — a negative credit is a requirement, and dcredit is the digit class, so -1 makes at least one digit mandatory.
Incorrect — credit values control character-class rules and length bonuses, not a length penalty.
Incorrect — a positive value would be that bonus; a negative value makes the digit mandatory.
Incorrect — it does the opposite, requiring a digit rather than banning one.
03Your internet-facing box uses key-only SSH, and you set 'even_deny_root' in faillock.conf. An attacker sprays guesses at the 'root' account from many IP addresses. What unintended effect can this cause, and how do you guard against it?
Incorrect — even_deny_root specifically makes root lockable, which is exactly the risk to weigh here.
Correct — even_deny_root lets a spray lock root, so you need an out-of-band way back in that faillock cannot block.
Incorrect — even_deny_root extends locking to root; it does not turn locking off for anyone else.
Incorrect — faillock counts failures and locks accounts; it does not add a second authentication factor.

Before you trust any of this, prove it. Create a throwaway account, fail its password the configured number of times, and watch faillock --user count the misses and then deny the login. Set its aging with chage and read it straight back with chage -l. Check the process ceiling with ulimit -Hu as that user. The config file is your intent, but the running behavior against a test account is the truth, and only the second one actually keeps anyone out.

Try this

Work through “Stale Credentials and Idle Accounts” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a bad PAM line can lock out every login. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related