Chage: Managing User Account Aging Policies, Enforcing Password Expiration Lifecycles, and Hardening Access Governance in Production
A frantic check with Human Resources confirms the worst: the contractor was officially offboarded eleven months ago, and their central single sign-on account was revoked the day they walked out the door. But this particular staging server, spun up in haste during an emergency migration two years ago, was never tied into central identity management. It relied on a local Linux account, and because default operating system settings rarely enforce password retirement, that local password had quietly remained valid for 412 days.
This scenario is every infrastructure teamβs quiet nightmare: the "zombie account" that outlives its purpose because nobody told the operating system when to cut the cord. The native Unix tool designed to prevent this exact failure is chageβshort for change aging.
Part of the standard Linux shadow password suite, chage gives administrators direct control over credential lifecycles, warning periods, and automatic account termination directly at the operating system layer.
If you suspect an account on your server is drifting indefinitely without an expiration date, you can inspect its lifecycle parameters immediately with a single command:
sudo chage -l contractor-ext-jgarcia
Last password change : Jul 14, 2025
Password expires : never
Password inactive : never
Account expires : never
Minimum number of days between password change : 0
Maximum number of days between password change : 99999
Number of days of warning before password expires : 7
Seeing Password expires: never and a maximum validity of 99999 days (more than 273 years) is the telltale sign of an unmanaged credential waiting to become a security incident.
What It Does in Plain English
The chage utility acts as an automated timekeeper for local user accounts. Rather than letting passwords and logins persist indefinitely, it allows administrators to establish enforceable schedules for four critical lifecycle events:
- Password Rotation: Setting how many days a password may be used before the user is forced to choose a new one.
- Advance Warning: Determining how many days in advance the system warns a user before their password expires.
- Inactivity Lockout: Defining a grace period after expiration, beyond which an unrotated account is automatically locked.
- Hard Account Termination: Setting an absolute calendar expiration date after which the account refuses all logins, regardless of whether the password is valid.
By enforcing these parameters, organizations eliminate reliance on human memory during offboarding, ensuring that orphaned credentials automatically self-terminate.
The Underpinning Architecture: /etc/shadow, struct spwd, and PAM
To understand how chage operates under the hood, we must look at how Linux stores authentication metadata in shadow(5) and how the Pluggable Authentication Module (pam_unix.so(8)) validates these values at login time.
The /etc/shadow Record Format
Each line in /etc/shadow represents a single user identity, split into nine colon-delimited fields:
| Field | Field Name | Description | Example Value |
|---|---|---|---|
| 1 | sp_namp |
User login name | jgarcia |
| 2 | sp_pwdp |
Encrypted password hash | $6$rounds=656000$... |
| 3 | sp_lstchg |
Date of last password change (epoch days) | 19840 |
| 4 | sp_min |
Minimum days required between changes | 1 |
| 5 | sp_max |
Maximum days password remains valid | 90 |
| 6 | sp_warn |
Days before expiration to warn user | 14 |
| 7 | sp_inact |
Days after expiration until account is locked | 7 |
| 8 | sp_expire |
Absolute account expiration date (epoch days) | 20025 |
| 9 | sp_flag |
Reserved field for future extensions | (empty) |
The C Data Structure: struct spwd
At the POSIX C library level, Linux abstracts entries inside /etc/shadow via the struct spwd definition defined in <shadow.h>:
struct spwd {
char *sp_namp; /* User login name */
char *sp_pwdp; /* Encrypted password hash (e.g., yescrypt, SHA-512) */
long sp_lstchg; /* Days since Jan 1, 1970 UTC that password was last changed */
long sp_min; /* Minimum days required between password changes */
long sp_max; /* Maximum days password remains valid */
long sp_warn; /* Days before password expiration to begin warning user */
long sp_inact; /* Days after password expiration until account is locked */
long sp_expire; /* Days since Jan 1, 1970 UTC when account becomes fully disabled */
unsigned long sp_flag; /* Reserved for future operating system extensions */
};
All temporal fields in /etc/shadow are recorded not as standard UNIX epoch timestamps (which measure elapsed seconds), but as epoch daysβthe total number of days elapsed since midnight UTC on January 1, 1970:
$$\text{EpochDay} = \left\lfloor \frac{t_{\text{UNIX}}}{86400} \right\rfloor$$
The PAM Verification Workflow
Whenever an interactive login occurs via SSH, local console, or su, the PAM authentication stack invokes pam_unix.so to evaluate the user's struct spwd record against current calendar time:
CurrentDay >= sp_expire?} C -- Yes --> D[Return PAM_ACCT_EXPIRED: Access Denied] C -- No --> E{sp_max != -1 AND
CurrentDay >= sp_lstchg + sp_max?} E -- No --> F{CurrentDay >= sp_lstchg + sp_max - sp_warn?} F -- Yes --> G[Issue Expiration Warning Prompt] F -- No --> H[Grant Access: Authenticated] G --> H E -- Yes --> I{sp_inact != -1 AND
CurrentDay >= sp_lstchg + sp_max + sp_inact?} I -- Yes --> J[Return PAM_AUTHTOK_EXPIRED: Account Locked] I -- No --> K[Return PAM_AUTHTOK_EXPIRED: Force Password Reset]
- Absolute Account Expiration (
sp_expire): Ifsp_expireis set and the current date has reached or passed that threshold, PAM immediately terminates the login withPAM_ACCT_EXPIRED. The user is barred entirely. - Maximum Password Validity (
sp_max): Ifsp_maxis configured, the system calculates the expiration date: $$T_{\text{expire}} = sp_lstchg + sp_max$$ If the current day is within the warning window ($T_{\text{expire}} - sp_warn$), PAM prints an advisory warning upon login. - Inactivity Lockout Enforcement (
sp_inact): If the expiration date passes, PAM evaluates whether the inactivity grace period has elapsed: $$T_{\text{lockout}} = sp_lstchg + sp_max + sp_inact$$ If the user attempts to log in after $T_{\text{lockout}}$, PAM marks the credential as administratively disabled. The user cannot update their password and must contact an administrator. - Forced Password Rotation: If the password has expired but the date is still within the inactivity grace window, the user is permitted to log in solely to execute
passwd(1)before shell access is granted. - Minimum Mutation Interval (
sp_min): When a user attempts to change their password, PAM verifies that at leastsp_mindays have passed since the previous change. This prevents users from immediately cycling through past passwords to circumvent password history rules.
Default baseline values for new accounts are maintained globally in login.defs(5) through directives such as PASS_MAX_DAYS and PASS_WARN_AGE. Running chage modifies the user's specific record in /etc/shadow, taking precedence over system-wide defaults.
Core Flags and Quick Reference
Modifying account aging requires administrative privileges (sudo), although any unprivileged user can run chage -l on their own username to view their current status.
| Flag | Long Option | Target Field | Operational Purpose |
|---|---|---|---|
-l |
--list |
All fields | Display account aging parameters in a readable format. |
-m |
--mindays |
sp_min |
Minimum number of days required between password changes. |
-M |
--maxdays |
sp_max |
Maximum number of days a password remains valid. |
-W |
--warndays |
sp_warn |
Number of days before expiration to display warning banners. |
-I |
--inactive |
sp_inact |
Grace period in days after expiration before account is locked. |
-E |
--expiredate |
sp_expire |
Absolute calendar date (YYYY-MM-DD) when account ceases to function. |
-d |
--lastday |
sp_lstchg |
Date of last password change (expressed in YYYY-MM-DD or epoch days). |
The Essential Baseline Diagnostic
Before modifying any configuration, check the target user's current settings:
sudo chage -l svc_deployer
Last password change : Jul 14, 2025
Password expires : never
Password inactive : never
Account expires : never
Minimum number of days between password change : 0
Maximum number of days between password change : 99999
Number of days of warning before password expires : 7
Password expires: never with a maximum interval of 99999 days. Under common regulatory standards such as PCI-DSS v4.0, SOC 2, and CIS Benchmarks, unconstrained local accounts represent an immediate compliance violation.5 Real-World Production Use Cases
| Scenario | Objective | Command Pattern | Key Fields Affected |
|---|---|---|---|
| 1. Compliance Audit | Verify current aging parameters across accounts | chage -l <user> |
All sp_* fields |
| 2. Policy Hardening | Enforce 90-day rotation with a 14-day warning | chage -m 1 -M 90 -W 14 <user> |
sp_min, sp_max, sp_warn |
| 3. Contractor Offboarding | Set a fixed automatic calendar expiration | chage -E YYYY-MM-DD <user> |
sp_expire |
| 4. Inactivity Lockout | Lock unrotated accounts after a 7-day grace period | chage -I 7 <user> |
sp_inact |
| 5. Onboarding Reset | Force immediate password change on first login | chage -d 0 <user> |
sp_lstchg = 0 |
Use Case 1: Auditing Account Metadata for Compliance Verification
Operational Scenario
During a security audit under PCI-DSS Section 8.3.6 and CIS Linux Benchmark 5.4.1.1, the infrastructure team must inspect an administrative account (secops_auditor) to ensure it meets mandatory rotation and expiration policies.
Command Invocation
sudo chage -l secops_auditor
Terminal Output
Last password change : Dec 01, 2025
Password expires : Mar 01, 2026
Password inactive : Mar 08, 2026
Account expires : Dec 31, 2026
Minimum number of days between password change : 1
Maximum number of days between password change : 90
Number of days of warning before password expires : 14
Line-by-Line Breakdown
Last password change : Dec 01, 2025(sp_lstchg = 20423): The exact calendar date when the password hash was generated, converted from epoch days.Password expires : Mar 01, 2026: Calculated dynamically as $sp_lstchg + sp_max$ ($20423 + 90 = 20513$ epoch days). Standard interactive logins without rotation will be blocked after this date.Password inactive : Mar 08, 2026: Computed as $sp_lstchg + sp_max + sp_inact$ ($20513 + 7 = 20520$ epoch days). After March 8, the account locks permanently and requires administrator intervention.Account expires : Dec 31, 2026(sp_expire = 20818): Hard ceiling where the PAM engine halts all authentication regardless of password status.Minimum number of days... : 1(sp_min = 1): Prevents rapid multi-rotation attacks designed to cycle through history buffers inpam_pwhistory.so.Maximum number of days... : 90(sp_max = 90): Enforces quarterly password changes.Number of days of warning... : 14(sp_warn = 14): Warning messages will display upon login beginning February 15, 2026 ($20513 - 14 = 20499$).
Next Administrative Action
To audit all accounts across a server fleet for compliance, run this awk command to identify accounts where maximum password age exceeds 90 days or is left unconfigured:
sudo awk -F: '($5 > 90 || $5 == "" || $5 == 99999) && $2 !~ /^(!|\*)/ {print $1, "MaxAge:"$5}' /etc/shadow
Use Case 2: Enforcing a 90-Day Rotation with a 14-Day Warning Window
Operational Scenario
A jump box provides shell access to a production Kubernetes cluster. To comply with internal security controls, all human operators must rotate their credentials every 90 days, receive warnings starting two weeks prior, and be restricted from changing their passwords more than once in a 24-hour window.
Command Invocation
sudo chage -m 1 -M 90 -W 14 jdoe
Verification and Terminal Output
sudo grep '^jdoe:' /etc/shadow
jdoe:$6$rounds=656000$q87s...$wK8Z...:20450:1:90:14:::
Line-by-Line Breakdown
jdoe: The user account name (sp_namp).$6$...: The SHA-512 password hash (sp_pwdp).20450: The day the password was updated, stored as an epoch day integer (sp_lstchg).1: Value ofsp_min. The user must keep a new password for at least 24 hours before changing it again.90: Value ofsp_max. Establishes the 90-day rotation deadline.14: Value ofsp_warn. Promptspam_unix.soto display warning messages between Day 76 and Day 90 of the cycle.:::: Unset values forsp_inact,sp_expire, andsp_flag.
Next Administrative Action
Test the user experience by logging in via SSH during the warning window to ensure the PAM notification displays properly:
Warning: Your password will expire in 12 days.
Please update your password immediately using 'passwd'.
Use Case 3: Hard Account Expiration for External Contractors
Operational Scenario
A penetration testing team is granted temporary access to a staging environment. Their engagement ends strictly at 23:59:59 UTC on October 15, 2026. The account must lock automatically at that moment without requiring manual removal by sysadmins.
Command Invocation
sudo chage -E 2026-10-15 ext_contractor
Verification and Terminal Output
sudo chage -l ext_contractor | grep "Account expires"
Account expires : Oct 15, 2026
Verify the underlying epoch day stored in /etc/shadow:
sudo awk -F: -v u="ext_contractor" '$1==u {print "Stored Epoch Day: " $8}' /etc/shadow
Stored Epoch Day: 20741
Line-by-Line Breakdown
2026-10-15: The ISO-8601 calendar date supplied to the command.chageconverts this date to epoch day 20741 ($20741 \times 86400 = 1,791,936,000$ seconds).sp_expire = 20741: Populates field 8 of/etc/shadow.- Runtime Behavior: On October 15, 2026, when current epoch days reach
20741,pam_unix.soreturnsPAM_ACCT_EXPIREDduring the account validation phase, denying access even if the user provides a valid password and SSH key.
Next Administrative Action
Ensure SSH authentication passes through PAM by verifying that UsePAM yes is active in /etc/ssh/sshd_config:
sudo sshd -T | grep -i "usepam"
usepam yes
UsePAM is set to no in sshd_config, OpenSSH authenticates public keys directly and bypasses PAM account checks, ignoring the sp_expire boundary configured by chage.Use Case 4: Implementing an Inactivity Lockout Grace Period
Operational Scenario
Engineers rotate passwords every 90 days. If an engineer is away on leave when their password expires, company policy provides a 7-day grace period during which they can log in and update their expired credential. If they fail to do so within 7 days, the account locks automatically.
Command Invocation
sudo chage -M 90 -I 7 devops_engineer
Verification and Terminal Output
sudo chage -l devops_engineer
Last password change : Jan 01, 2026
Password expires : Apr 01, 2026
Password inactive : Apr 08, 2026
Account expires : never
Minimum number of days between password change : 0
Maximum number of days between password change : 90
Number of days of warning before password expires : 7
Line-by-Line Breakdown
Password expires : Apr 01, 2026: Point of password expiration ($sp_lstchg + 90$).Password inactive : Apr 08, 2026: Point of permanent lockout ($sp_lstchg + 90 + 7$).- Authentication States:
1. Before April 1: Normal access granted.
2. Between April 1 and April 8: Password is expired. PAM returns
PAM_AUTHTOK_EXPIREDand forces the user into an interactive password reset prompt before starting a shell. 3. After April 8: Inactivity limit is exceeded. PAM disables the account. The user receives:Your account has expired; please contact your system administrator.
Next Administrative Action
If an account becomes locked due to inactivity, an administrator can restore access by updating the last change date to today:
sudo chage -d $(date +%Y-%m-%d) devops_engineer
Use Case 5: Forcing a Password Change on Initial Login
Operational Scenario
When provisioning new virtual machines via cloud-init or configuration management, local accounts are created with temporary random passwords. New users must be forced to replace these temporary credentials on their very first login.
Command Invocation
sudo chage -d 0 new_employee
Verification and Terminal Output
sudo grep '^new_employee:' /etc/shadow
new_employee:$6$rounds=656000$x71T...$mN91...:0:0:90:7:::
Line-by-Line Breakdown
sp_lstchg = 0(Field 3): Setting the last change date to0sets the timestamp to the beginning of the UNIX epoch (January 1, 1970).- PAM Evaluation: Because $sp_lstchg = 0$, the inequality $\text{CurrentDay} \ge sp_lstchg + sp_max$ evaluates to $\text{CurrentDay} \ge 90$, which is instantly true for all modern dates. PAM interprets epoch day
0as an explicit signal to force an immediate password rotation.
Next Administrative Action
When new_employee connects via SSH, their session is intercepted immediately:
$ ssh new_employee@jumpbox01.internal
new_employee@jumpbox01.internal's password: [TemporaryPassword]
You are required to change your password immediately (administrator enforced).
Current password: [TemporaryPassword]
New password: [SecureEnterprisePassword123!]
Retype new password: [SecureEnterprisePassword123!]
passwd: password updated successfully
Connection to jumpbox01.internal closed.
The user reconnects using their new password, and normal shell access is granted.
Operational Pitfalls and How to Avoid Them
| Potential Risk | Root Cause | Preventive Solution |
|---|---|---|
| Service Outages | Applying sp_max to system daemon accounts causes automated jobs to fail |
Ensure sp_max = -1 (or 99999) for non-interactive service accounts |
| Password Cycling | Leaving sp_min = 0 lets users rapidly cycle passwords to reuse old ones |
Set sp_min >= 1 in conjunction with pam_pwhistory.so remember=5 |
| SSH Key Bypass | UsePAM no in SSH config bypasses account expiration checks |
Ensure UsePAM yes is enforced inside /etc/ssh/sshd_config |
Pitfall 1: Service Account Lockout Due to Blanket Expiration Policies
The Problem: An automated script runs chage -M 90 across all accounts in /etc/passwd. Ninety days later, background backup jobs, CI/CD runners, and database accounts (svc_postgres, backup_worker) fail without warning because their passwords have expired.
The Fix: Explicitly exempt service and automation accounts by setting sp_max to -1 (or 99999):
sudo chage -M -1 -I -1 -m 0 svc_postgres
Ensure service accounts have locked password fields (! or * in field 2 of /etc/shadow) and use SSH keys or dedicated API tokens rather than passwords.
Pitfall 2: Circumventing Password History with sp_min = 0
The Problem: If sp_min is left at 0, an employee prompted to change their password can run passwd five times in a row, cycling through dummy passwords to satisfy remember=5 in pam_pwhistory.so, before immediately switching back to their old, compromised password.
The Fix: Enforce sp_min >= 1 on all interactive user accounts to prevent changing passwords more than once per day:
sudo chage -m 1 <username>
Pitfall 3: Timezone Confusion on Expiration Dates
The Problem: Setting chage -E 2026-10-15 converts the date to epoch day 20741 at 00:00:00 UTC. For an engineer working in San Francisco (UTC-7 / PDT), the account will lock at 17:00:00 local time on October 14.
The Fix: When setting calendar expirations for distributed teams, confirm the UTC boundary explicitly:
TZ=UTC sudo chage -l <username>
Best Practices: Automation and Configuration Management
Configuring chage by hand on individual servers does not scale across fleet infrastructure. Production environments should define user aging parameters declaratively via configuration management and continuous compliance scripts.
Declarative User Management with Ansible
Using Ansible's built-in user module, account aging policies can be applied consistently across hundreds of hosts:
---
- name: Enforce Credential Aging and Account Lifecycles
hosts: all
become: true
tasks:
- name: Configure standard human engineer identity
ansible.builtin.user:
name: jdoe
password_expire_max: 90 # sp_max
password_expire_min: 1 # sp_min
password_expire_warn: 14 # sp_warn
password_lock_inactive: 7 # sp_inact
expires: 1791936000 # sp_expire (Epoch seconds for 2026-10-15)
state: present
- name: Ensure automated service accounts are immune to expiration
ansible.builtin.user:
name: svc_appdeploy
password_expire_max: -1
password_expire_min: 0
password_lock_inactive: -1
expires: -1
shell: /usr/sbin/nologin
state: present
Automated Compliance Verification Script
This lightweight Bash script can be integrated into your CI/CD pipelines or executed as a scheduled cron job to flag non-compliant accounts across your fleet:
#!/usr/bin/env bash
# ==============================================================================
# AUDIT-PASSWD-AGING.SH
# Scans /etc/shadow for non-compliant interactive user accounts
# ==============================================================================
set -euo pipefail
MAX_ALLOWED_DAYS=90
AUDIT_FAILURE=0
echo "[*] Initiating PAM and /etc/shadow credential compliance scan..."
while IFS=: read -r username password lastchange min max warn inact expire reserved; do
# Skip system accounts and locked/disabled hashes
if [[ "$password" =~ ^(\*|\!|\!\!) ]] || [[ -z "$password" ]]; then
continue
fi
# Read UID from /etc/passwd to isolate human accounts (UID >= 1000)
user_uid=$(id -u "$username" 2>/dev/null || echo 0)
if [[ "$user_uid" -ge 1000 ]] && [[ "$username" != "nobody" ]]; then
# Check Maximum Password Age (sp_max)
if [[ -z "$max" ]] || [[ "$max" -gt "$MAX_ALLOWED_DAYS" ]] || [[ "$max" -eq -1 ]]; then
echo "[VIOLATION] User '$username' (UID $user_uid) has non-compliant Max Age: '${max:-UNSET}' (Max allowed: $MAX_ALLOWED_DAYS)"
AUDIT_FAILURE=1
fi
# Check Inactive Grace Period (sp_inact)
if [[ -z "$inact" ]] || [[ "$inact" -gt 14 ]] || [[ "$inact" -eq -1 ]]; then
echo "[WARNING] User '$username' (UID $user_uid) lacks an inactivity lockout window: '${inact:-UNSET}'"
fi
# Check Minimum Password Age (sp_min)
if [[ -z "$min" ]] || [[ "$min" -lt 1 ]]; then
echo "[WARNING] User '$username' (UID $user_uid) has Minimum Age < 1: '${min:-0}'"
fi
fi
done < /etc/shadow
if [[ "$AUDIT_FAILURE" -eq 1 ]]; then
echo "[!] Security scan completed: POLICY VIOLATIONS DETECTED."
exit 1
else
echo "[+] Security scan completed: All interactive accounts satisfy aging baselines."
exit 0
fi
Authoritative Documentation & References
To explore Linux credential management, PAM internals, and system hardening further, refer to the following resources:
chage(1)β Linux User Commands Reference Manualshadow(5)β Shadow Password Suite File Formatspam_unix(8)β Linux-PAM UNIX Authentication Modulelogin.defs(5)β Shadow Password Suite Configurationpasswd(5)β Password File Formats and Structure- ArchWiki: Security and User Management β Hardening Accounts, Passwords, and Authentication
Today's Takeaway
Open a terminal on your primary development machine or test server right now and run sudo chage -l $(whoami). If the output says Password expires: never, your local environment is running without lifecycle protection. In under five minutes, you can run sudo chage -m 1 -M 90 -W 14 -I 7 $(whoami) to establish a 90-day rotation schedule, a 14-day warning window, and a 7-day inactivity lockout. Setting these parameters ensures that if a local credential is ever forgotten or abandoned, the operating system itself will step in to revoke accessβturning access governance from a fragile manual chore into an automatic, built-in security control.