Skip to content

PAM and Linux Authentication

PAM (Pluggable Authentication Modules) is the framework that controls how Linux processes authenticate users. When you log in via SSH, sudo, su, or a GUI, PAM decides whether to grant or deny access - and it does so through a stack of configurable modules. Understanding PAM is essential for enforcing password policies, integrating MFA, and troubleshooting authentication failures.


Stores basic account information. World-readable by design (needed for username→UID resolution).

Format: username:x:UID:GID:GECOS:home:shell
alice:x:1001:1001:Alice Smith:/home/alice:/bin/bash
│ │ │ │ │ │ │
│ │ │ │ │ │ └─ Login shell
│ │ │ │ │ └─ Home directory
│ │ │ │ └─ GECOS field (full name, optional info)
│ │ │ └─ Primary GID
│ │ └─ UID (1000+ = regular users; 0 = root)
│ └─ 'x' = password stored in /etc/shadow
└─ Username
Terminal window
# View account information
getent passwd alice # query NSS (works for LDAP users too)
id alice # UID, GID, groups
finger alice # verbose user info (if installed)
# System accounts (login disabled)
grep '/sbin/nologin\|/bin/false' /etc/passwd # can't log in interactively
# Add a user (high-level)
useradd -m -s /bin/bash -c "Alice Smith" -G sudo alice
passwd alice # set password
# Modify a user
usermod -aG docker alice # add to docker group (-a = append; don't remove existing)
usermod -L alice # lock account (prepends ! to shadow hash)
usermod -U alice # unlock account
usermod -s /sbin/nologin alice # disable interactive login
# Delete a user
userdel alice # removes user, keeps home directory
userdel -r alice # removes user AND home directory

Stores hashed passwords and aging policy. Readable only by root.

Format: username:hash:lastchange:min:max:warn:inactive:expire:reserved
alice:$6$salt$hash...:19800:0:90:14:7:20000:
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └─ Account expiry date (days since epoch)
│ │ │ │ │ │ └─ Days inactive before account disabled
│ │ │ │ │ └─ Warn N days before password expires
│ │ │ │ └─ Password max age (days)
│ │ │ └─ Password min age (days, prevents rapid cycling)
│ │ └─ Days since epoch when password was last changed
│ └─ Hashed password
└─ Username
Hash format:
$1$ = MD5 (obsolete, do not use)
$5$ = SHA-256
$6$ = SHA-512 (current standard)
$y$ = yescrypt (modern; Fedora/Debian default)
! = account locked (prepended to hash)
* = no password (cannot log in via password)
Terminal window
# Change password
passwd alice # as root: change any user's password
passwd # as user: change own password
# Check password expiry status
chage -l alice # list password aging info
chage -M 90 alice # set max age to 90 days
chage -W 14 alice # warn 14 days before expiry
chage -I 7 alice # disable account 7 days after password expires
chage -E 2026-12-31 alice # set account expiry date
chage -d 0 alice # force password change on next login
# Audit: find accounts with no password (empty hash)
awk -F: '($2 == "" || $2 == "!" ) { print $1 }' /etc/shadow
# Audit: find UID 0 accounts other than root (privilege escalation risk)
awk -F: '($3 == 0) { print $1 }' /etc/passwd
/etc/group format: groupname:x:GID:member1,member2,...
/etc/gshadow: groupname:hash:admins:members
# List groups a user belongs to
groups alice
id alice
# Create and manage groups
groupadd -g 1100 developers
usermod -aG developers alice
gpasswd -d alice developers # remove alice from developers group
# Check who is in a group
getent group sudo
getent group docker

Login request (SSH, sudo, console, graphical)
PAM framework reads: /etc/pam.d/<service>
Processes the module stack in order:
TYPE CONTROL MODULE
auth required pam_unix.so ← check /etc/shadow hash
auth required pam_google_authenticator.so ← check TOTP
account required pam_nologin.so ← check if logins are disabled
account required pam_unix.so ← check account expiry
session required pam_limits.so ← apply resource limits
session optional pam_lastlog.so ← show last login info
TypeWhat it does
authAuthenticate the user (verify password, OTP, key)
accountCheck account validity (expired? locked? allowed to log in?)
passwordHandle password changes and policy enforcement
sessionSet up/tear down the session (mount home dir, set limits, logging)
FlagMeaning
requiredMust succeed; failure always causes final AUTH FAIL (other modules still run)
requisiteMust succeed; failure causes immediate AUTH FAIL (no more modules run)
sufficientIf succeeds (and no prior required failure), grant access immediately
optionalFailure only matters if it’s the only module of this type
includeInclude another PAM file’s rules inline

Terminal window
# Key files in /etc/pam.d/
ls /etc/pam.d/
# common-auth ← shared auth stack (included by sshd, sudo, etc.)
# common-account ← shared account checks
# common-password ← shared password update rules
# common-session ← shared session setup
# sshd ← SSH-specific overrides
# sudo ← sudo-specific stack
# login ← console login
# su ← su command
auth [success=1 default=ignore] pam_unix.so nullok
auth requisite pam_deny.so
auth required pam_permit.so
auth optional pam_cap.so
Terminal window
# See ssh-hardening.mdx for full setup
# /etc/pam.d/sshd - add before other auth lines:
auth required pam_google_authenticator.so nullok
# /etc/ssh/sshd_config:
ChallengeResponseAuthentication yes
AuthenticationMethods publickey,keyboard-interactive # key + TOTP

Password Complexity Policy with pam_pwquality

Section titled “Password Complexity Policy with pam_pwquality”
Terminal window
# Install
apt install libpam-pwquality
# /etc/pam.d/common-password:
password requisite pam_pwquality.so retry=3
# /etc/security/pwquality.conf:
minlen = 14 # minimum length
minclass = 3 # minimum character classes (upper, lower, digit, special)
maxrepeat = 3 # max consecutive same characters
maxsequence = 4 # max sequential characters (abcd, 1234)
dcredit = -1 # require at least 1 digit (-N = require N chars of type)
ucredit = -1 # require at least 1 uppercase
lcredit = -1 # require at least 1 lowercase
ocredit = -1 # require at least 1 special character
difok = 5 # minimum characters that must differ from old password
gecoscheck = 1 # reject passwords that match user's GECOS field
dictcheck = 1 # reject dictionary words
# Test a password against the policy
passwd alice
# Or test directly:
python3 -c "
import subprocess
result = subprocess.run(['pwscore'], input='MyPassword123!', capture_output=True, text=True)
print(result.stdout) # quality score 0-100
"
Terminal window
# /etc/pam.d/common-auth - add BEFORE pam_unix.so:
auth required pam_faillock.so preauth silent deny=5 unlock_time=900 fail_interval=300
auth [success=1 default=ignore] pam_unix.so nullok
auth [default=die] pam_faillock.so authfail deny=5 unlock_time=900
# /etc/pam.d/common-account:
account required pam_faillock.so
# /etc/security/faillock.conf (modern alternative to inline args):
deny = 5 # lock after 5 failures
unlock_time = 900 # unlock after 15 minutes
fail_interval = 300 # count failures within 5 minute window
admin_group = sudo # exempt sudo group from lockout
# Check lockout status
faillock --user alice
# Manually unlock a locked account
faillock --user alice --reset
# List all locked accounts
faillock

sudo allows users to run commands as root (or another user) with controlled access.

Terminal window
visudo # opens /etc/sudoers in your default EDITOR with validation
# Or create a drop-in file (preferred - avoids editing sudoers directly)
visudo -f /etc/sudoers.d/alice
# Format: USER HOST=(RUNAS) NOPASSWD:COMMAND
# Grant alice full sudo on all hosts (dangerous - avoid)
alice ALL=(ALL:ALL) ALL
# Grant alice sudo without password (for automation - dangerous)
alice ALL=(ALL) NOPASSWD: ALL
# Grant alice sudo for specific commands only
alice ALL=(ALL) /bin/systemctl restart nginx, /bin/systemctl status *
# Grant a group sudo
%sudo ALL=(ALL:ALL) ALL # % = group (this is the default for sudo group)
%developers ALL=(ALL) /usr/bin/docker
# No password for specific command
alice ALL=(ALL) NOPASSWD: /sbin/ip addr add *, /sbin/ip addr del *
# Restrict to specific target user (run as www-data, not root)
alice ALL=(www-data) /var/www/html/deploy.sh
# Use NOPASSWD carefully - only for commands with no destructive capability
Terminal window
# Test what alice can do with sudo
sudo -l -U alice # as root: list alice's sudo capabilities
sudo -l # as alice herself: what can I do?
# Run a single command as root
sudo systemctl restart nginx
# Switch to root shell (use sparingly)
sudo -i # root login shell (reads root's .profile)
sudo -s # root shell but keeps current environment
# Run a command as a specific user (not root)
sudo -u www-data php /var/www/maintenance.php
# See who has used sudo recently
journalctl -u sudo # systemd logs
grep sudo /var/log/auth.log
Terminal window
# Every sudo command is logged to /var/log/auth.log / journald
grep "COMMAND=" /var/log/auth.log | tail -20
# Format: alice : ... COMMAND=/usr/bin/apt install ...
# Auditd: log all sudo escalations
cat >> /etc/audit/rules.d/sudo.rules << 'EOF'
-a always,exit -F arch=b64 -F path=/usr/bin/sudo -S execve -k sudo_exec
EOF
auditctl -R /etc/audit/rules.d/sudo.rules
ausearch -k sudo_exec -ts recent

Factorsusudo
AuthenticationRequires target user’s password (e.g., root password)Requires your own password
Audit trailNo per-command loggingEvery command logged with your username
GranularityAll or nothing (full shell as target user)Can limit to specific commands
Root passwordMust share root password with adminsNo root password needed; no need to share
Best useLegacy systems; switching to non-root user interactivelyAlmost all legitimate escalation use cases
Terminal window
# su - switch to root (requires root password)
su - # root login shell
# su to another user
su - alice # requires alice's password (or root's password if you're root)
# sudo vs su equivalent:
sudo -i # equivalent to: su - root (but uses YOUR password, and logs it)
sudo su - # equivalent but runs su through sudo (audit-visible)
# Best practice: disable direct root login and root password
sudo passwd -l root # lock root password (prevents su - root entirely)
# All escalation must go through sudo - ensures audit trail

Resource Limits - /etc/security/limits.conf

Section titled “Resource Limits - /etc/security/limits.conf”

PAM’s pam_limits module enforces per-user or per-group resource limits:

Terminal window
# /etc/security/limits.conf (read by pam_limits)
# Format: domain type item value
# Limit max open files for all users
* soft nofile 1024 # soft limit (user can raise up to hard)
* hard nofile 65536 # hard limit (absolute ceiling)
# Limit processes for a specific user (prevent fork bombs)
alice hard nproc 100 # max 100 processes
alice hard fsize 1048576 # max file size 1GB (in KB)
# Unlimited limits for a privileged user (e.g., database)
postgres soft nofile 65536
postgres hard nofile 65536
postgres soft nproc unlimited
postgres hard nproc unlimited
# Lock memory (for security-sensitive apps) - prevent swap write
@developers hard memlock 1048576 # max 1GB locked
Terminal window
# Check current limits for a process
cat /proc/PID/limits
# Check limits for current shell session
ulimit -a # show all
ulimit -n # open files limit
ulimit -u # max user processes
# Set limits for current session (up to the hard limit)
ulimit -n 4096 # increase open files for this session