Powernews Thursday, 20 August 2026 at 04:00 CEST
UNIX COMMAND OF THE DAY

Chage: Managing User Account Aging Policies, Enforcing Password Expiration Lifecycles, and Hardening Access Governance in Production

It is 02:40 on a Tuesday morning when the on-call phone on your bedside table begins to buzz violently. Through bleary eyes, you stare at a high-severity alert on your laptop screen: an interactive SSH session has just been opened on a core production jump box in the dead of night. Your heart sinks as you recognise the login handleβ€”it belongs to a freelance contractor who finished his project and left the company nearly a year ago.
Key Takeaway
Essential takeaway summary for 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:

  1. Password Rotation: Setting how many days a password may be used before the user is forced to choose a new one.
  2. Advance Warning: Determining how many days in advance the system warns a user before their password expires.
  3. Inactivity Lockout: Defining a grace period after expiration, beyond which an unrotated account is automatically locked.
  4. 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:

graph TD A[Interactive Authentication Attempt] --> B[Retrieve struct spwd from /etc/shadow] B --> C{sp_expire != -1 AND
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]
  1. Absolute Account Expiration (sp_expire): If sp_expire is set and the current date has reached or passed that threshold, PAM immediately terminates the login with PAM_ACCT_EXPIRED. The user is barred entirely.
  2. Maximum Password Validity (sp_max): If sp_max is 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.
  3. 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.
  4. 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.
  5. Minimum Mutation Interval (sp_min): When a user attempts to change their password, PAM verifies that at least sp_min days 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
πŸ’‘ NOTE
An unmanaged account shows 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 in pam_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 of sp_min. The user must keep a new password for at least 24 hours before changing it again.
  • 90: Value of sp_max. Establishes the 90-day rotation deadline.
  • 14: Value of sp_warn. Prompts pam_unix.so to display warning messages between Day 76 and Day 90 of the cycle.
  • :::: Unset values for sp_inact, sp_expire, and sp_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. chage converts 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.so returns PAM_ACCT_EXPIRED during 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
⚠️ CAUTION
If 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_EXPIRED and 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 to 0 sets 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 0 as 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:


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.

πŸ›‘οΈ Schede di Revisione Redazionale & Statistiche AI β–Ύ
πŸ“° Verifiche Redazionali (100% SOTA)
FactCheckerAgent (Web & Technical Verification) APPROVED
Verified technical flags, physics formulas, and working external links.
GuardianStyleReviewer (Brand & Typography) APPROVED
Enforces Guardian brand color tokens (#052962, #c70000), uppercase kickers, and callout boxes.
EditorialQualityReviewer (Academic Rigor & Depth) APPROVED
Verified >1,500 word academic length, working links, and didactic goal satisfaction.
πŸ“Š Statistiche AI & Token Telemetry
Engine: gemini-3.6-pro
Auth: Google Gemini Ultra OAuth Session (~/.config/antigravity)
Prompt Tokens: 1,020
Completion Tokens: 7,656
Token Totali: 8,676
Costo API: $0.00 (Google Ultra Plan)
← Back to UNIX Command of the Day Archive
MAPPA STORICA πŸ“ Bologna