PAM and login
PAM is the authentication middleware between programs (sshd, login, sudo) and the actual authentication mechanism. The stack has four groups: auth, account, password, session. Control flags (required/requisite/sufficient) determine whether a failure is fatal or skippable.
You add LDAP authentication to a server and now local users cannot log in anymore — even root. Or a pam_faillock module locks accounts after three bad attempts, but it is stopping SSH key logins too, and you cannot figure out why. Or you need to enforce resource limits (open file descriptors, max processes) on a per-user basis and ulimit settings keep getting reset on login. Or an auditor asks you to explain every step that happens between typing a password and getting a shell. All of these questions have the same answer: PAM — the authentication middleware layer that every login program on a modern Linux system delegates to.
After this lesson you can read a PAM configuration file and predict what happens when any module succeeds or fails, explain the four PAM groups and the four control flags, trace the sshd login flow through PAM, understand what nsswitch.conf does and why it matters for user lookup, and safely modify a PAM stack for a common real-world change.
PAM decouples authentication policy from authentication implementation. Before PAM (early 1990s), every program that needed to authenticate users — login, ftp, rsh — had its own hard-coded call to the Unix password verification function. Changing from passwords to tokens meant recompiling every program. PAM (Pluggable Authentication Modules, 1995) inserted a standard API between programs and authentication backends: programs call pam_authenticate(), PAM reads the policy from /etc/pam.d/<service>, and loads the specified modules at runtime.
# The /etc/pam.d/ directory: one file per service
ls /etc/pam.d/
# common-auth common-account common-password common-session
# login sshd sudo su
# gdm-password cron ...
# Programs link against libpam and call it — they contain NO auth logic
ldd /usr/bin/sudo | grep pam
# libpam.so.0 => /lib/x86_64-linux-gnu/libpam.so.0
# The PAM API from the program's perspective:
# 1. pam_start(service, user, conv, &handle) — opens a PAM transaction
# 2. pam_authenticate(handle, flags) — runs the 'auth' stack
# 3. pam_acct_mgmt(handle, flags) — runs the 'account' stack
# 4. pam_open_session(handle, flags) — runs the 'session' stack
# 5. pam_end(handle, retval) — cleans up
# The conversation function (conv) is how PAM asks the user for a password
# or a token — the program provides the I/O, PAM provides the policyThis architecture means you can add 2FA, LDAP, smart cards, or biometrics to every login program simultaneously by editing PAM configuration — without recompiling anything. It also means a mistake in PAM configuration can break every authentication channel at once.
PAM has four module groups (called “types”), each with a distinct role. A PAM configuration file is a sequence of lines, each matching one module to one group.
# File format: type control module [arguments]
cat /etc/pam.d/sshd
# @include common-auth <- include shared auth stack
# @include common-account
# @include common-session
# session optional pam_motd.so motd=/run/motd.dynamic
# session optional pam_motd.so noupdate
# session required pam_limits.so <- enforce ulimits
# session [success=1 default=ignore] pam_succeed_if.so service in cron quiet use_uid
# session required pam_unix.so
# The four groups:
# auth: Is the user who they claim to be? (password check, token, key)
# account: Is the account allowed to log in? (expired? locked? time restriction?)
# password: How passwords are changed (called by passwd(1))
# session: What happens after successful auth (mount home, set limits, log, env)The @include directive pulls in /etc/pam.d/common-auth, /etc/pam.d/common-account, etc. Debian/Ubuntu centralizes shared policy in these common files so that changing “require 2FA” in common-auth applies to every service at once. On RHEL/Fedora, authselect manages these common files via profiles.
Control flags determine whether a module’s failure is fatal or skippable. This is the subtlest part of PAM and the source of most configuration mistakes.
# The four primary control flags:
# required: module is run; failure means the stack fails — BUT continue running
# remaining modules (so the caller doesn't know WHICH module failed)
# requisite: like required, but IMMEDIATELY fails if this module fails
# (stops the stack; faster fail; reveals position of failure)
# sufficient: if this module SUCCEEDS (and nothing required failed before it),
# the stack succeeds immediately — skip remaining modules
# optional: run it; ignore the return value for pass/fail purposes
# Read /etc/pam.d/common-auth:
cat /etc/pam.d/common-auth
# auth [success=1 default=ignore] pam_unix.so nullok
# auth requisite pam_deny.so
# auth required pam_permit.so
# The bracket notation [success=N default=ignore] is a generalization:
# success=1 means "on success, skip the NEXT 1 line" (jump over pam_deny.so)
# default=ignore means "on any other result, ignore and continue"
# Together: if pam_unix.so (password check) succeeds, jump over pam_deny.so
# and land on pam_permit.so (explicit success). If pam_unix.so fails,
# fall through to pam_deny.so (explicit failure).
# Why 'required' continues on failure instead of stopping immediately:
# Timing attack mitigation. If password check failure exits immediately but
# LDAP check takes 200ms, an attacker can tell whether a user exists locally
# or remotely by measuring authentication time. Running all required modules
# regardless normalizes the timing.The requisite vs required distinction matters: use requisite early in the stack when failing fast is both safe and desired (e.g., checking that the account is not locked before asking for a password). Use required when you want timing normalization or when later modules need to run regardless.
nsswitch.conf controls WHERE user and group information is looked up — before PAM even runs. PAM authenticates, but it cannot authenticate a user that doesn’t exist in the name service.
# /etc/nsswitch.conf: name service switch configuration
cat /etc/nsswitch.conf
# passwd: files systemd
# group: files systemd
# shadow: files
# hosts: files mdns4_minimal [NOTFOUND=return] dns myhostname
# networks: files
# ...
# 'passwd: files systemd' means:
# 1. Look in /etc/passwd first ('files')
# 2. If not found, ask systemd-userdb ('systemd') — handles systemd-homed users
# In an enterprise: 'passwd: files ldap' adds LDAP as a fallback
# getent respects nsswitch — grep /etc/passwd does not
getent passwd alice # checks files then systemd
grep '^alice:' /etc/passwd # checks only /etc/passwd
# The resolution order matters: 'files' first means local users shadow LDAP users
# with the same name — an important security property
# 'ldap' before 'files' would allow LDAP to override local users (dangerous)
# When adding LDAP:
# passwd: files ldap — local users always win; LDAP fills the gaps
# group: files ldap
# shadow: files — shadow STAYS files-only (LDAP handles its own auth)
# Test name resolution without logging in
getent passwd someuser # works even for LDAP users
id someuser # resolves UID, GID, all groupsThe nsswitch layer answers “does this user exist and what are their UID/GID?”. PAM then answers “can this user authenticate?”. Both are needed for a successful login. The classic mistake when adding LDAP is putting ldap before files in nsswitch — this allows a rogue LDAP server to shadow local root or system accounts.
The session group is where post-authentication setup happens — and where many production gotchas live. pam_limits.so enforces ulimit settings; pam_env.so sets environment variables; pam_unix.so in session logs the login to wtmp/utmp.
# pam_limits.so reads /etc/security/limits.conf and /etc/security/limits.d/
cat /etc/security/limits.d/myapp.conf
# @myapp soft nofile 65536
# @myapp hard nofile 131072
# @myapp soft nproc 4096
# @myapp hard nproc 8192
# domain: @group means all members of 'myapp' group
# type: soft = default; hard = ceiling (user can raise up to hard)
# item: nofile = open file descriptors; nproc = max processes
# These limits are applied by pam_limits.so in the session group
# They ONLY apply to login sessions that go through PAM
# If your service uses systemd, use systemd unit file directives instead:
# LimitNOFILE=65536 <- in [Service] section of the unit file
# pam_env.so: set environment variables from a file
cat /etc/security/pam_env.conf
# EDITOR DEFAULT=/usr/bin/vim
# TZ DEFAULT=UTC
# Trace a full SSH login through PAM to see all modules called:
# (requires debug build or strace — here we show the logical sequence)
# 1. sshd authenticates the key/password
# 2. sshd calls pam_authenticate() -> runs common-auth stack
# 3. sshd calls pam_acct_mgmt() -> runs common-account (is account valid?)
# 4. sshd calls pam_open_session() -> session modules:
# - pam_loginuid.so (sets /proc/self/loginuid — audit subsystem)
# - pam_limits.so (applies limits.conf)
# - pam_env.so (sets environment)
# - pam_unix.so (logs to utmp/wtmp)
# - pam_motd.so (prints /etc/motd)
# A sufficient module in the auth stack short-circuiting session:
# pam_sss.so (SSSD) with 'sufficient' control: if LDAP auth succeeds,
# pam_unix.so (local password check) is skipped — the session still
# runs because session modules are not short-circuited by 'sufficient' in authThe sufficient control in the auth group does NOT skip session modules. Session always runs in full after auth succeeds. This surprises operators who add a sufficient LDAP module expecting it to bypass pam_limits — it does not. The limits still apply.
Diagnosing why SSH key authentication succeeds but the user gets immediately disconnected.
Symptom: ssh alice@server shows the banner, then disconnects with “Connection closed by server”. Password authentication also fails. Root SSH works fine.
# Step 1: check sshd auth log
journalctl -u ssh --since '5 minutes ago' | grep alice
# Jun 21 14:50:01 server sshd[9821]: Accepted publickey for alice from 10.0.0.5
# Jun 21 14:50:01 server sshd[9821]: pam_unix(sshd:session): session opened for user alice
# Jun 21 14:50:01 server sshd[9821]: pam_limits(sshd:session): Could not set limit for 'nproc'
# Jun 21 14:50:01 server sshd[9821]: fatal: PAM: pam_open_session(): System error
# Jun 21 14:50:01 server sshd[9821]: Disconnected from user alice 10.0.0.5
# Auth succeeded! The disconnect is in the SESSION group, not auth.
# pam_limits.so failed trying to apply the nproc limit.
# Step 2: check limits.conf
grep -r 'alice\|@alice\|nproc' /etc/security/limits.conf /etc/security/limits.d/
# @myapp hard nproc 4096
# alice is in group 'myapp' — that limit is fine on its own
# Step 3: check the hard limit on the system
cat /proc/sys/kernel/pid_max
# 32768
# The limit.conf set nproc hard=4096 which is fine
# Step 4: check if alice's account has hit the limit system-wide
grep 'alice' /etc/security/limits.d/*.conf
# alice hard nproc 0
# Found it! Someone set nproc hard=0 for alice specifically — no processes allowed
# Step 5: fix and test
sudo sed -i '/alice.*nproc.*0/d' /etc/security/limits.d/alice.conf
# Retry SSH — alice now gets a shell
# Lesson: PAM failures in the session group look like auth failures to the user
# but are logged distinctly. Always check sshd journal for pam_* lines,
# not just the 'Accepted' line.▸Why this works
Why does RHEL use authselect instead of letting you edit PAM files directly? On RHEL 8+, authselect manages the common-auth, common-account, common-session, and common-password files through profiles (like sssd, winbind, nis). Editing the common files directly means authselect will overwrite your changes next time it runs, breaking your auth stack silently. The correct RHEL workflow is authselect select sssd --force (to switch to an SSSD-backed profile) then authselect apply-changes. Debian/Ubuntu has no equivalent tool — you edit the common files directly — which is why PAM tutorials written for Debian often break on RHEL.
▸Common mistake
The most dangerous PAM mistake is adding a sufficient module at the top of the auth stack without understanding the implication: if that module returns success for ANY user (including root), the rest of the auth stack is bypassed entirely. A classic incident: an operator added auth sufficient pam_permit.so to common-auth “temporarily” to debug a login issue, then forgot to remove it. For the next three days, anyone could SSH into the server without a password, because pam_permit.so always returns PAM_SUCCESS. Always test PAM changes on a secondary SSH session before closing the original; never use pam_permit.so in production.
A PAM auth stack has: (1) required pam_faillock.so preauth, (2) sufficient pam_unix.so, (3) required pam_deny.so. A user provides the correct local password. Which modules run and what is the result?
PAM decouples authentication policy from implementation. Programs call libpam; PAM reads /etc/pam.d/<service> and runs the configured modules. Four groups: auth (verify identity), account (check policy — locked? expired?), password (change credentials), session (set up environment: limits, env vars, audit log). Four control flags: required (failure continues stack, entire result fails), requisite (failure stops stack immediately), sufficient (success stops stack immediately — skip remaining), optional (result ignored). /etc/nsswitch.conf controls where user records are looked up (files, ldap, systemd) — it runs before PAM. Always put files before ldap in nsswitch. Session modules (pam_limits.so, pam_env.so) are NOT skipped by sufficient in the auth group — they always run after auth succeeds. Modify PAM from a second open session and test before closing the original.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.