Every time someone types a password on a Linux system — whether logging into SSH, running sudo, or unlocking a screen — the request passes through PAM (Pluggable Authentication Modules). It's the gatekeeper layer sitting between applications and the actual authentication logic, and honestly, it's one of the most powerful yet consistently under-configured security controls on Linux servers.
Here's the thing: most distributions ship with PAM defaults that prioritize usability over security. Null passwords might be allowed. Account lockouts after brute-force attempts? Often disabled. Password complexity requirements are minimal or absent entirely. And multi-factor authentication via FIDO2 or TOTP remains a manual opt-in that many admins never get around to implementing.
I've seen production servers running default PAM configs for years. Don't be that admin.
This guide walks through hardening every critical PAM module on modern Linux distributions — Ubuntu 24.04 LTS and RHEL 9/derivatives — with tested configurations, working code examples, and explanations of why each setting matters. We'll cover brute-force lockout with pam_faillock, password quality enforcement with pam_pwquality, modern password hashing with yescrypt, FIDO2 hardware key authentication, TOTP-based two-factor, IP and group-based access restrictions, and resource limits. Every configuration targets CIS Benchmark recommendations where applicable.
How PAM Works: Architecture You Need to Understand
Before changing any configuration, you need to understand PAM's architecture — because a misconfiguration here can lock every user out of the system, including root. Yes, really.
PAM organizes its modules into four stacks, each handling a different phase of the authentication lifecycle:
- auth — Verifies user identity (passwords, tokens, biometrics)
- account — Checks whether the authenticated user is allowed to access the service (account expiration, time-of-day restrictions, IP-based access control)
- password — Handles password changes (complexity requirements, hashing algorithms, history enforcement)
- session — Sets up and tears down user sessions (resource limits, home directory mounting, logging)
Each module in a stack has a control flag that determines what happens when it succeeds or fails:
- required — Must succeed, but PAM continues checking remaining modules before returning a result. This actually serves a security purpose: it prevents attackers from inferring which specific check failed.
- requisite — Must succeed, and PAM returns immediately on failure. Use this when you want fast rejection.
- sufficient — If this module succeeds and no prior
requiredmodule has failed, authentication succeeds immediately without checking further modules. - optional — Success or failure doesn't really matter unless this is the only module for that stack type.
Configuration files live in /etc/pam.d/ with one file per service — sshd, sudo, login, and so on. On Debian/Ubuntu, common settings are factored into common-auth, common-account, common-password, and common-session. On RHEL/Fedora, the equivalent files are system-auth and password-auth, managed through the authselect tool.
Critical safety rule: Before making any PAM changes, always keep an open root shell in a separate terminal. If your configuration breaks authentication, that shell is your only way back in. I can't stress this enough.
Brute-Force Protection with pam_faillock
The single most impactful PAM hardening change you can make is enabling account lockout after repeated failed login attempts. Without it, attackers can try passwords indefinitely against SSH, console, or any PAM-aware service.
The pam_faillock module replaced the deprecated pam_tally2 starting with RHEL 8 and Ubuntu 20.04, and it's now the standard recommended by CIS Benchmarks for all modern distributions.
RHEL 9 / AlmaLinux 9 / Rocky Linux 9 Configuration
On RHEL-family systems, the recommended approach is to use authselect rather than editing PAM files directly. This ensures your changes survive system updates:
# Enable the faillock feature in authselect
sudo authselect enable-feature with-faillock
# Verify it was applied
sudo authselect current
Then configure the lockout parameters in /etc/security/faillock.conf:
# /etc/security/faillock.conf
# Lock account after 5 failed attempts
deny = 5
# Count failures within this window (seconds)
fail_interval = 900
# Unlock automatically after this duration (seconds)
# Set to 0 for permanent lock (admin must manually unlock)
unlock_time = 900
# Also lock root account (enable with caution)
even_deny_root
# Separate, shorter unlock time for root
root_unlock_time = 60
# Log failed attempts to syslog
audit
# Directory for per-user failure records
dir = /var/run/faillock
Ubuntu 24.04 LTS / Debian 12 Configuration
On Debian-family systems, you'll edit PAM files directly. The module ordering is critical here — pam_faillock.so preauth must appear before pam_unix.so, and pam_faillock.so authfail must appear after it. Get this wrong and the lockout either won't work or will lock people out on the first attempt:
# /etc/pam.d/common-auth
# Check faillock BEFORE attempting authentication
auth required pam_faillock.so preauth deny=5 unlock_time=900 fail_interval=900 audit
# Standard Unix password check
auth [success=1 default=ignore] pam_unix.so nullok
# Record failure AFTER authentication fails
auth [default=die] pam_faillock.so authfail deny=5 unlock_time=900 fail_interval=900
# Deny by default
auth requisite pam_deny.so
auth required pam_permit.so
Add the account check in /etc/pam.d/common-account:
# /etc/pam.d/common-account (add before other account lines)
account required pam_faillock.so
Managing Locked Accounts
# View failed attempts for a specific user
sudo faillock --user jdoe
# Reset a locked-out user
sudo faillock --user jdoe --reset
# View all locked users (check all system users)
sudo faillock
CIS Benchmark alignment: CIS recommends deny=5, unlock_time=900, and fail_interval=900 for both RHEL 9 and Ubuntu 24.04. If you enable even_deny_root, keep root_unlock_time short (60 seconds) — you really don't want to permanently lock yourself out during a legitimate emergency at 2 AM.
Password Quality Enforcement with pam_pwquality
Weak passwords remain the top attack vector for brute-force compromises. The pam_pwquality module enforces complexity rules when users set or change passwords. It checks against a dictionary (using cracklib), prevents trivial modifications of old passwords, and requires specific character class mixtures.
Let's set it up properly.
Configuring /etc/security/pwquality.conf
# /etc/security/pwquality.conf — CIS-aligned hardened configuration
# Minimum password length (CIS: at least 14)
minlen = 16
# Require at least one of each character class
# Negative values = mandatory count; positive = credit toward length
dcredit = -1 # At least 1 digit
ucredit = -1 # At least 1 uppercase
lcredit = -1 # At least 1 lowercase
ocredit = -1 # At least 1 special character
# Minimum different character classes required (out of 4)
minclass = 3
# Minimum characters that must differ from previous password
difok = 5
# Reject passwords with more than 3 consecutive identical characters
maxrepeat = 3
# Reject passwords with more than 3 consecutive same-class characters
maxclassrepeat = 3
# Check password against cracklib dictionary
dictcheck = 1
# Reject passwords containing the username
usercheck = 1
# Enforce these rules even for root
enforce_for_root
# Number of retries before returning error
retry = 3
Enabling in the PAM Stack
On Ubuntu/Debian, make sure /etc/pam.d/common-password includes:
# /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
password requisite pam_deny.so
password required pam_permit.so
On RHEL 9, the pwquality module is enabled by default — just edit /etc/security/pwquality.conf with the settings above and you're good to go.
Password History (Preventing Reuse)
You know those users who just toggle between the same two passwords? Yeah, let's fix that. The pam_pwhistory module prevents password cycling:
# Add to /etc/pam.d/common-password (Ubuntu) or system-auth (RHEL)
# BEFORE the pam_unix.so line
password required pam_pwhistory.so remember=12 use_authtok enforce_for_root retry=3
This stores the last 12 password hashes in /etc/security/opasswd and rejects any new password that matches. CIS Benchmarks recommend remembering at least 5 previous passwords, but 12 provides much stronger protection against cycling attacks.
Modern Password Hashing with yescrypt
The hashing algorithm used to store passwords in /etc/shadow is critically important. If an attacker gains read access to shadow (through a privilege escalation exploit, for instance), the hash algorithm determines how quickly they can crack passwords offline.
Here's where things stand in 2026:
- yescrypt — Default on Debian 11+, Ubuntu 22.04+, Fedora 35+, and Arch Linux. Memory-hard, GPU-resistant, and significantly more expensive to attack than SHA-512. Hashes begin with
$y$. - SHA-512 — Still the default on RHEL 9 and CentOS Stream 9. Reasonable security but not memory-hard, making it more vulnerable to GPU-based attacks. Hashes begin with
$6$. - bcrypt/blowfish — Mature and still acceptable. Hashes begin with
$2b$. - MD5 / DES — Dangerously obsolete. If you see
$1$or no prefix in/etc/shadow, upgrade immediately. Seriously, stop reading and go fix that first.
Checking Your Current Algorithm
# Check what hashing algorithm is in use
grep "^ENCRYPT_METHOD" /etc/login.defs
# Inspect actual hashes (look at the prefix)
sudo awk -F: '$2 != "!" && $2 != "*" {print $1, substr($2,1,4)}' /etc/shadow
Switching to yescrypt on RHEL 9
# Edit /etc/login.defs
sudo sed -i 's/^ENCRYPT_METHOD .*/ENCRYPT_METHOD YESCRYPT/' /etc/login.defs
# Also update PAM to use yescrypt
# In /etc/pam.d/system-auth, change the pam_unix.so password line:
# password sufficient pam_unix.so sha512 shadow nullok ...
# to:
# password sufficient pam_unix.so yescrypt shadow try_first_pass use_authtok
Important: Changing the hashing algorithm doesn't automatically re-hash existing passwords. Users must change their passwords for the new algorithm to take effect. To force this across all accounts:
# Force all non-system users to change password at next login
sudo awk -F: '$3 >= 1000 && $1 != "nobody" {print $1}' /etc/passwd | \
xargs -I {} sudo chage -d 0 {}
FIDO2 Hardware Key Authentication with pam_u2f
Hardware security keys provide the strongest form of authentication available — they're phishing-resistant, can't be copied remotely, and require physical presence. If you're serious about locking down privileged access, this is where you should be heading.
The pam_u2f module from Yubico brings FIDO2 and U2F authentication to any PAM-aware service, including SSH, sudo, and console login.
Installation
# Ubuntu/Debian
sudo apt install libpam-u2f
# RHEL 9 / AlmaLinux 9 (EPEL required)
sudo dnf install epel-release
sudo dnf install pam-u2f pamu2fcfg
Enrolling FIDO2 Keys
# Create the credential directory
mkdir -p ~/.config/Yubico
# Enroll your primary key (touch the key when it blinks)
pamu2fcfg > ~/.config/Yubico/u2f_keys
# Enroll a backup key (ALWAYS do this — losing your only key locks you out)
pamu2fcfg -n >> ~/.config/Yubico/u2f_keys
I cannot overstate how important the backup key step is. I've seen admins skip it and then scramble when their primary key fails. Always enroll at least two.
For a system-wide deployment with centralized credential storage:
# Create a central mapping directory
sudo mkdir -p /etc/u2f-mappings
# Enroll a key for a specific user
pamu2fcfg -u admin | sudo tee -a /etc/u2f-mappings/u2f_keys
# Enroll backup key for the same user
pamu2fcfg -u admin -n | sudo tee -a /etc/u2f-mappings/u2f_keys
Configuring PAM for Two-Factor (Password + Key)
Add the FIDO2 requirement after password authentication. For sudo:
# /etc/pam.d/sudo
@include common-auth
auth required pam_u2f.so authfile=/etc/u2f-mappings/u2f_keys cue nouserok
The cue option displays a prompt telling the user to touch their key. The nouserok option allows users who haven't enrolled a key to still authenticate with password only — remove it once all users have enrolled keys.
Configuring for Passwordless Authentication
# /etc/pam.d/sudo — FIDO2 passwordless with password fallback
auth sufficient pam_u2f.so authfile=/etc/u2f-mappings/u2f_keys cue pinverification=1
@include common-auth
The pinverification=1 option requires the FIDO2 device PIN, adding a second factor (something you have + something you know) without needing the system password.
SELinux Caveat on RHEL/Fedora
SELinux may block access to the u2f_keys credential file. If authentication silently succeeds without prompting for the key (which is a subtle and easy-to-miss failure mode), check audit logs and fix file contexts:
# Restore correct SELinux contexts
sudo restorecon -Rv /etc/u2f-mappings/
# If that fails, run on next boot
sudo fixfiles onboot
TOTP Two-Factor Authentication with Google Authenticator
For environments where hardware keys aren't practical — remote servers, automated systems, or users who don't have physical tokens — TOTP (Time-based One-Time Password) is a solid alternative. It works with smartphone apps like Google Authenticator, Authy, or FreeOTP.
Installation and Setup
# Ubuntu/Debian
sudo apt install libpam-google-authenticator
# RHEL 9 / AlmaLinux 9
sudo dnf install epel-release
sudo dnf install google-authenticator
Each user runs the setup interactively:
# Run as the user (not root)
google-authenticator
# Recommended answers for server hardening:
# - Time-based tokens: yes
# - Update .google_authenticator file: yes
# - Disallow multiple uses of same token: yes
# - Increase time window: no (keep the default 30-second window)
# - Rate limiting: yes
PAM Configuration for SSH
# /etc/pam.d/sshd — add after @include common-auth
auth required pam_google_authenticator.so nullok
Update SSH daemon configuration:
# /etc/ssh/sshd_config
UsePAM yes
ChallengeResponseAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
This configuration requires both an SSH key and a TOTP code. The nullok option in the PAM line allows users who haven't configured TOTP yet to still log in — remove it after all users have completed setup.
# Restart SSH to apply
sudo systemctl restart sshd
IP and Group-Based Access Control with pam_access
The pam_access module restricts which users can log in from which locations. This is an essential defense-in-depth control: even if credentials are compromised, the attacker can't use them from an unauthorized network.
It's one of those modules that takes five minutes to configure and can save you from a nightmare scenario.
Configuring /etc/security/access.conf
# /etc/security/access.conf
# Allow sysadmins group from anywhere
+ : (sysadmins) : ALL
# Allow developers only from office network and VPN
+ : (developers) : 10.0.0.0/8 192.168.1.0/24
# Allow root only from local console
+ : root : LOCAL
# Deny everyone else
- : ALL : ALL
Rules are evaluated top to bottom, and the first match wins. The format is: permission : users/groups : origins.
Enabling in the PAM Stack
# /etc/pam.d/common-account (Ubuntu) or system-auth (RHEL)
account required pam_access.so
For SSH-specific restrictions, also add it to /etc/pam.d/sshd and make sure UsePAM yes is set in /etc/ssh/sshd_config.
Group-Based Login Restrictions with pam_listfile
For a simpler group-based approach, pam_listfile restricts login to members of groups listed in a flat file:
# /etc/pam.d/sshd — only allow groups listed in the file
auth required pam_listfile.so onerr=fail item=group sense=allow file=/etc/ssh/login.group.allowed
# /etc/ssh/login.group.allowed (one group per line)
sysadmins
developers
dbadmins
Resource Limits with pam_limits
Resource limits prevent individual users or processes from consuming excessive system resources. Think fork bombs, memory exhaustion attacks, or just that one developer who accidentally spawns 10,000 processes.
Configuring /etc/security/limits.conf
# /etc/security/limits.conf
# Prevent fork bombs: limit max processes
* hard nproc 4096
root hard nproc unlimited
# Limit open files (prevent file descriptor exhaustion)
* hard nofile 65535
# Limit core dump size (prevent sensitive data in core dumps)
* hard core 0
# Limit max memory for non-root users (in KB)
@developers hard as 8388608
Ensure pam_limits.so is loaded in the session stack:
# /etc/pam.d/common-session (Ubuntu) or system-auth (RHEL)
session required pam_limits.so
Restricting Root Console Access with pam_securetty
The pam_securetty module restricts direct root logins to specific TTY devices listed in /etc/securetty. On a hardened server, root should never log in directly — administrators should use personal accounts and escalate via sudo.
# Empty the securetty file to prevent root login on all terminals
sudo cp /etc/securetty /etc/securetty.bak
sudo truncate -s 0 /etc/securetty
This doesn't affect sudo or su — it only blocks direct root logins on console TTY devices. SSH root login should be separately disabled via PermitRootLogin no in /etc/ssh/sshd_config, since pam_securetty doesn't reliably cover SSH connections.
Removing Dangerous Defaults
Several default PAM settings that ship with most distributions should be changed on any hardened system. These are easy wins.
Remove nullok from pam_unix
The nullok argument allows accounts with empty passwords to authenticate. On any server — really, any server — this should be removed:
# Find and remove nullok from auth lines
# In /etc/pam.d/common-auth (Ubuntu) or system-auth (RHEL):
# BEFORE:
# auth [success=1 default=ignore] pam_unix.so nullok
# AFTER:
# auth [success=1 default=ignore] pam_unix.so
Ensure pam_systemd Is Present
If you create custom PAM configurations, always include pam_systemd.so in the session stack. Omitting it causes subtle but annoying issues with systemd user slices, cgroup management, and task limits:
# Must be in session stack
session optional pam_systemd.so
Testing PAM Changes Safely
PAM misconfigurations can immediately lock you out of a system. I'm going to repeat that because it bears repeating. Follow this testing protocol every single time:
- Keep a root shell open. Before any change, open a separate terminal with a root session. Do not close it until testing is complete.
- Test in a VM first. Snapshot the VM before making changes so you can instantly revert.
- Use pamtester. This utility lets you test PAM configurations without actually logging in:
# Install pamtester
sudo apt install pamtester # Ubuntu/Debian
sudo dnf install pamtester # RHEL/Fedora
# Test authentication for a specific service
pamtester sshd jdoe authenticate
pamtester sudo jdoe authenticate
- Check logs. PAM logs to
/var/log/auth.log(Ubuntu) or/var/log/secure(RHEL). Watch them in real time while testing:
sudo tail -f /var/log/auth.log
- Verify from a new session. Open a completely new SSH connection or console login. Never — and I mean never — close your root recovery shell until the new session works.
Complete Hardened Configuration Reference
Here's a consolidated checklist of all the PAM hardening changes we've covered, organized by module:
| Module | File | Key Settings | CIS Benchmark |
|---|---|---|---|
| pam_faillock | /etc/security/faillock.conf | deny=5, unlock_time=900, fail_interval=900 | 5.3.2 (RHEL 9) |
| pam_pwquality | /etc/security/pwquality.conf | minlen=16, dcredit=-1, ucredit=-1, lcredit=-1, ocredit=-1 | 5.3.1 (RHEL 9) |
| pam_pwhistory | common-password / system-auth | remember=12, enforce_for_root | 5.3.3 (RHEL 9) |
| pam_unix | common-password / system-auth | yescrypt (or sha512), remove nullok | 5.3.4 (RHEL 9) |
| pam_u2f | Per-service (sudo, sshd) | authfile, cue, pinverification=1 | N/A (recommended) |
| pam_access | /etc/security/access.conf | IP/group-based allow/deny rules | N/A (recommended) |
| pam_limits | /etc/security/limits.conf | nproc, nofile, core limits | 1.5 (RHEL 9) |
| pam_securetty | /etc/securetty | Empty file (block all root TTY) | N/A (recommended) |
Automating PAM Hardening with Ansible
If you're managing more than a handful of servers (and let's be real, most of us are), doing this manually on each box isn't sustainable. Here's a minimal Ansible playbook for the critical settings:
# pam-hardening.yml
---
- name: Harden PAM configuration
hosts: all
become: true
tasks:
- name: Configure pwquality
ansible.builtin.copy:
dest: /etc/security/pwquality.conf
content: |
minlen = 16
dcredit = -1
ucredit = -1
lcredit = -1
ocredit = -1
minclass = 3
difok = 5
maxrepeat = 3
dictcheck = 1
usercheck = 1
enforce_for_root
retry = 3
owner: root
group: root
mode: "0644"
- name: Configure faillock
ansible.builtin.copy:
dest: /etc/security/faillock.conf
content: |
deny = 5
fail_interval = 900
unlock_time = 900
even_deny_root
root_unlock_time = 60
audit
dir = /var/run/faillock
owner: root
group: root
mode: "0644"
- name: Enable faillock on RHEL
ansible.builtin.command:
cmd: authselect enable-feature with-faillock
when: ansible_os_family == "RedHat"
changed_when: true
- name: Set password hashing to yescrypt
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "^ENCRYPT_METHOD"
line: "ENCRYPT_METHOD YESCRYPT"
Frequently Asked Questions
What is the difference between pam_tally2 and pam_faillock?
pam_tally2 was the older module for tracking failed login attempts, but it's been deprecated and removed from PAM entirely. pam_faillock is its replacement on all modern Linux distributions (RHEL 8+, Ubuntu 20.04+). The key improvements include support for tracking screensaver lockout failures, a dedicated configuration file (/etc/security/faillock.conf), integration with authselect on RHEL, and separate unlock times for root vs. regular accounts. If your system still references pam_tally2, you need to migrate to pam_faillock before upgrading PAM.
How do I recover if PAM misconfiguration locks everyone out?
If you get locked out, boot into single-user mode (add init=/bin/bash to the kernel command line in GRUB), remount the root filesystem as read-write with mount -o remount,rw /, then fix the PAM configuration files in /etc/pam.d/. On RHEL systems, running authselect select sssd --force resets PAM to safe defaults. This is exactly why keeping a root shell open during changes is so important — it gives you a way to undo mistakes without rebooting.
Should I use yescrypt or SHA-512 for password hashing?
Use yescrypt if your distribution supports it (Debian 11+, Ubuntu 22.04+, Fedora 35+). Yescrypt is memory-hard, meaning it requires significant RAM to compute each hash — making GPU-based and ASIC-based cracking attacks orders of magnitude more expensive than attacking SHA-512 hashes. SHA-512 is still acceptable on RHEL 9, but consider switching to yescrypt when your distribution fully supports it. Just remember that existing passwords won't be re-hashed automatically; users need to change them.
Can I use FIDO2 keys for SSH authentication without PAM?
Yes — OpenSSH 8.2 and later natively support FIDO2/U2F key types (ecdsa-sk and ed25519-sk) without needing pam_u2f. However, pam_u2f gives you more flexibility: it can enforce FIDO2 for sudo, console login, and any other PAM-aware service, not just SSH. For SSH-only FIDO2, native OpenSSH support is simpler; for system-wide FIDO2 enforcement, pam_u2f is the way to go.
How do PAM changes interact with SELinux and AppArmor?
SELinux can block PAM modules from reading credential files (like /etc/u2f-mappings/u2f_keys for pam_u2f) if the file contexts aren't set correctly. Always run restorecon -Rv on any new files you create for PAM, and check /var/log/audit/audit.log for AVC denial messages if authentication fails silently. AppArmor profiles may also need updating if they restrict the PAM module from accessing its configuration files. On Fedora/RHEL, fixfiles onboot resolves most SELinux labeling issues after new PAM module installations.