Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Securing Linux User Accounts and Password Policies for Compliance

When Linux account security becomes a compliance problem

In the beginning, a Linux server is usually “just a server.” One admin account, a couple of application users, maybe a shared jump box. Then time passes. Teams change. Vendors come and go. A temporary account becomes permanent. A service account gets reused for a second application. Someone disables password aging “for convenience” during an incident and forgets to revert it.

Nothing breaks immediately. That is exactly why this becomes dangerous. The risk grows quietly: dormant accounts, weak password rules, inconsistent lockout behavior, and missing audit trails. In enterprise environments—especially where Bangladesh Bank expectations and CIS-aligned baselines matter—this is where “we think we’re fine” turns into “we can’t prove we’re fine.”

In this guide, we will harden Linux user accounts and password policies in a way that is operationally realistic: persistent across reboots, measurable, and verifiable. We will avoid PAM-only theory and focus on controls we can enforce and demonstrate during audits.

Scope and compliance intent

We are aligning with the spirit of CIS Linux benchmarks and Bangladesh Bank security expectations: strong authentication hygiene, controlled account lifecycle, reduced attack surface from dormant/privileged accounts, and evidence-friendly verification. Exact benchmark item numbers vary by distribution and benchmark version, so we will implement controls that map cleanly to common audit questions:

  • Are password aging and complexity enforced consistently?
  • Are inactive accounts handled?
  • Are privileged accounts controlled and traceable?
  • Are default/legacy behaviors removed?
  • Can we prove the system is configured as intended?

Prerequisites and assumptions

Before we touch anything, we need to be explicit about the environment. These assumptions keep the steps safe and predictable in production:

  • Platform: Linux. The commands below are designed for modern enterprise distributions (RHEL/Rocky/Alma 8/9, Ubuntu 20.04/22.04/24.04, Debian 11/12). Where behavior differs, we will call it out.
  • Access: We must have root access or a sudo-capable administrative account. We will use sudo in commands so the steps work in hardened environments where direct root SSH is disabled.
  • Change control: We should schedule a maintenance window if this is a shared system. Password policy changes can affect service accounts, automation, and human users.
  • Backups: We should have a recent snapshot or backup. At minimum, we will back up the configuration files we modify.
  • Authentication model: This guide assumes local accounts exist. If we use centralized identity (LDAP/AD/SSSD), we still need local hardening for break-glass accounts and system users, but we must coordinate policy with the identity provider.
  • Firewall: No firewall changes are required for these controls. However, account lockouts can look like “network issues” to application teams, so we will include verification and troubleshooting steps that reduce confusion.

Step 1: Establish a baseline and capture evidence

Before changing policy, we will capture the current state. This gives us two things: a rollback reference and audit evidence showing what changed. We will also identify risky accounts (UID 0, empty passwords, non-login shells) early.

sudo -n true 2>/dev/null || echo "Sudo may prompt for a password; ensure we have sudo access."

This checks whether sudo can run non-interactively. If it prints a message, sudo likely requires a password prompt, which is fine—we just need to be ready for it during the session.

set -e

echo "=== OS ==="
cat /etc/os-release || true
uname -a

echo "=== Key files (permissions and ownership) ==="
ls -l /etc/passwd /etc/shadow /etc/group /etc/gshadow

echo "=== Accounts with UID 0 (should be only root) ==="
awk -F: '($3==0){print $1":"$3":"$7}' /etc/passwd

echo "=== Accounts with empty password field in /etc/shadow (should be none) ==="
sudo awk -F: '($2==""){print $1}' /etc/shadow

echo "=== Password aging defaults (login.defs) ==="
grep -E '^s*(PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_WARN_AGE|ENCRYPT_METHOD)b' /etc/login.defs || true

echo "=== Current per-user aging (sample) ==="
for u in root $(awk -F: '($3>=1000 && $1!="nobody"){print $1}' /etc/passwd | head -n 5); do
  echo "--- $u ---"
  sudo chage -l "$u" || true
done

We now have a snapshot of the OS, critical file permissions, UID 0 accounts, any empty-password accounts, and current password aging defaults. The chage output is especially useful during audits because it shows effective policy per user, not just configuration intent.

Step 2: Back up the configuration we will change

We are going to modify system-wide defaults that affect account creation and password aging. Before doing that, we will create timestamped backups with strict permissions so we can roll back safely.

TS=$(date -u +%Y%m%dT%H%M%SZ)
sudo install -d -m 0700 /root/niilaa-account-hardening-backups

for f in /etc/login.defs /etc/default/useradd; do
  if [ -f "$f" ]; then
    sudo cp -a "$f" "/root/niilaa-account-hardening-backups/$(basename "$f").$TS"
  fi
done

sudo ls -l /root/niilaa-account-hardening-backups

This creates a protected backup directory and copies the relevant files with attributes preserved. The timestamp makes it easy to correlate changes with a change request or incident timeline.

Step 3: Enforce password aging defaults for new accounts

In many environments, the biggest compliance gap is inconsistency: some accounts have aging, others do not. We will set strong defaults so that any new local account created from this point forward inherits compliant aging behavior. This aligns with CIS-style expectations and Bangladesh Bank’s emphasis on controlled credential lifecycle.

We will implement conservative, enterprise-friendly defaults:

  • PASS_MAX_DAYS: 90 (forces periodic rotation for local accounts)
  • PASS_MIN_DAYS: 1 (prevents immediate rotation loops that bypass history controls)
  • PASS_WARN_AGE: 14 (gives users time to rotate without disruption)
  • ENCRYPT_METHOD: SHA512 (strong hashing method for local passwords)

First, we will update /etc/login.defs in a controlled way. We will remove conflicting existing lines and append our intended values so the final state is unambiguous.

sudo cp -a /etc/login.defs "/etc/login.defs.bak.$TS"

sudo sed -i 
  -e '/^s*PASS_MAX_DAYSb/d' 
  -e '/^s*PASS_MIN_DAYSb/d' 
  -e '/^s*PASS_WARN_AGEb/d' 
  -e '/^s*ENCRYPT_METHODb/d' 
  /etc/login.defs

sudo tee -a /etc/login.defs >/dev/null <<'EOF'

# NIILAA: Account and password aging defaults (CIS/Bangladesh Bank aligned)
PASS_MAX_DAYS   90
PASS_MIN_DAYS   1
PASS_WARN_AGE   14
ENCRYPT_METHOD  SHA512
EOF

We backed up /etc/login.defs, removed any existing conflicting directives, and appended a clearly labeled block. This makes the configuration deterministic and easier to defend during audits.

Now we will verify the effective values in the file.

grep -E '^s*(PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_WARN_AGE|ENCRYPT_METHOD)b' /etc/login.defs

This confirms the system-wide defaults that will apply to newly created accounts going forward.

Step 4: Ensure new accounts are not created as “active forever”

Password aging defaults help, but account lifecycle matters too. A common enterprise failure mode is creating accounts that never expire and are never revisited. We will set a default inactivity lock so that if a password expires and the account remains unused, it becomes unusable after a defined period.

We will set INACTIVE=30, meaning: after a password expires, the account is disabled after 30 days of inactivity. This is a practical balance between security and operational continuity.

if [ -f /etc/default/useradd ]; then
  sudo cp -a /etc/default/useradd "/etc/default/useradd.bak.$TS"
else
  sudo touch /etc/default/useradd
  sudo chmod 0644 /etc/default/useradd
fi

sudo sed -i -e '/^s*INACTIVEs*=/d' /etc/default/useradd
echo 'INACTIVE=30' | sudo tee -a /etc/default/useradd >/dev/null

sudo useradd -D | grep -E 'INACTIVE|EXPIRE'

We ensured /etc/default/useradd exists, set the default inactivity value, and then verified the default account creation policy using useradd -D. This affects new accounts created after this change.

Step 5: Bring existing human accounts into compliance

Defaults only help future accounts. In real environments, the audit finding usually comes from existing accounts that were created years ago. We will apply aging and inactivity settings to existing human users (typically UID >= 1000) while avoiding system/service accounts.

We will do this carefully: first we will list the candidate accounts, then we will apply changes, and finally we will verify per-user results.

First, we will identify “human” accounts by UID range and shell. This is not perfect, but it is a safe starting point for enterprise systems.

awk -F: '($3>=1000 && $1!="nobody"){print $1":"$3":"$7}' /etc/passwd | sort

This prints likely human accounts with UID and shell. We should review the list and confirm it matches our environment’s account model.

Now we will apply aging and inactivity settings to those accounts. We will set:

  • Maximum password age: 90 days
  • Minimum password age: 1 day
  • Warning: 14 days
  • Inactive lock after password expiry: 30 days
while IFS=: read -r user uid _; do
  if [ "$uid" -ge 1000 ] && [ "$user" != "nobody" ]; then
    sudo chage -M 90 -m 1 -W 14 -I 30 "$user" || true
  fi
done < <(awk -F: '{print $1":"$3":"$7}' /etc/passwd)

This iterates through accounts and applies the policy to UID >= 1000 users. We used || true to avoid a single problematic account stopping the entire run; we will validate results next.

Now we will verify a sample of accounts to confirm the policy is effective.

for u in $(awk -F: '($3>=1000 && $1!="nobody"){print $1}' /etc/passwd | head -n 10); do
  echo "--- $u ---"
  sudo chage -l "$u"
done

This shows the effective aging configuration per user. During compliance reviews, this output is often more persuasive than “we set it in a config file,” because it demonstrates enforcement at the account level.

Step 6: Lock down risky account conditions

Now we will address the quiet risks that accumulate: extra UID 0 accounts, empty passwords, and accounts that should never be interactive. These are the kinds of findings that show up in both CIS-aligned scans and regulator-driven assessments.

6.1 Ensure only root has UID 0

Multiple UID 0 accounts create untraceable privilege paths. We will identify them and then decide whether to remove, lock, or reassign. In production, we should not delete accounts blindly; we should first confirm ownership and purpose.

awk -F: '($3==0){print $1":"$3":"$7}' /etc/passwd

If anything other than root appears, we have a high-risk condition. The safest immediate containment is to lock the account while we investigate.

for u in $(awk -F: '($3==0 && $1!="root"){print $1}' /etc/passwd); do
  sudo passwd -l "$u" || true
  sudo usermod -L "$u" || true
done

This locks any non-root UID 0 accounts, preventing password-based authentication. We still need to follow up with proper remediation (reassign UID, remove sudoers entries, and validate no automation depends on it), but this reduces immediate exposure.

6.2 Ensure no accounts have empty passwords

An empty password field in /etc/shadow is an emergency-grade misconfiguration. We will detect and lock such accounts immediately.

EMPTY_USERS=$(sudo awk -F: '($2==""){print $1}' /etc/shadow | tr 'n' ' ')
echo "Empty-password users: ${EMPTY_USERS:-none}"

This prints any accounts with empty password hashes. If the list is not empty, we will lock them.

for u in $(sudo awk -F: '($2==""){print $1}' /etc/shadow); do
  sudo passwd -l "$u" || true
done

This prevents password-based login for those accounts. After containment, we should either set a strong password, convert the account to a non-login service account, or remove it based on business ownership.

6.3 Ensure system/service accounts are non-interactive

Many breaches start with a service account that was never meant to log in interactively but can. We will identify accounts with shells that allow login and then decide which should be set to /usr/sbin/nologin (or /sbin/nologin depending on the distribution).

First, we will detect the correct nologin path on this system.

NOLOGIN_BIN=""
if command -v nologin >/dev/null 2>&1; then
  NOLOGIN_BIN="$(command -v nologin)"
elif [ -x /usr/sbin/nologin ]; then
  NOLOGIN_BIN="/usr/sbin/nologin"
elif [ -x /sbin/nologin ]; then
  NOLOGIN_BIN="/sbin/nologin"
else
  echo "nologin binary not found; install util-linux or equivalent package for this distro."
  exit 1
fi
echo "Using nologin at: $NOLOGIN_BIN"

This determines a valid non-interactive shell path. We will use it consistently to avoid broken shells that cause unexpected behavior.

Now we will list likely service accounts that have interactive shells. We will focus on UID < 1000 (system range), excluding root, and excluding accounts already set to nologin/false.

awk -F: -v nologin="$NOLOGIN_BIN" '
($3<1000 && $1!="root" && $7!="/bin/false" && $7!=nologin){
  print $1":"$3":"$7
}' /etc/passwd | sort

This produces a review list. In production, we should validate each account’s purpose before changing shells, because some system accounts are used for maintenance tasks.

If we confirm an account should never be interactive, we will set its shell to nologin.

# Example safe pattern: apply only to accounts we explicitly approve.
# We will not mass-change shells automatically in enterprise environments.
# Replace "some_service_user" only after confirming ownership and impact.
true

We intentionally avoid mass-changing service account shells without explicit approval. This is one of those changes that can break automation quietly, and compliance is not worth an outage.

Step 7: Control privileged access with sudo hygiene

Compliance is not only about passwords; it is about who can become root and how traceable that path is. We will ensure sudo is installed, restrict sudo access to a controlled group, and enforce logging-friendly behavior. We will do this without relying on PAM-only mechanisms.

7.1 Confirm sudo is installed and active

First we will confirm sudo exists and is usable. This matters because many environments disable direct root SSH and rely on sudo for privileged operations.

command -v sudo
sudo -V | head -n 2

This confirms sudo is present and prints its version header. If sudo is missing, we should install it using the distribution package manager under change control.

7.2 Ensure sudoers is clean and uses an admin group

We will verify that sudo access is granted through a group (commonly sudo on Debian/Ubuntu and wheel on RHEL-like systems). Group-based privilege is easier to audit and reduces “one-off” exceptions.

if getent group wheel >/dev/null; then
  ADMIN_GROUP="wheel"
elif getent group sudo >/dev/null; then
  ADMIN_GROUP="sudo"
else
  ADMIN_GROUP="wheel"
  sudo groupadd "$ADMIN_GROUP"
fi
echo "Admin group: $ADMIN_GROUP"

echo "=== Sudoers group entries ==="
sudo grep -R --line-number -E '^s*%('wheel'|sudo)b' /etc/sudoers /etc/sudoers.d 2>/dev/null || true

This selects an admin group based on what exists and prints current group-based sudo rules. If neither group existed, we created wheel to establish a controlled pattern.

Now we will add a dedicated sudoers drop-in file for our admin group. We will also validate syntax with visudo before it can break sudo.

sudo tee "/etc/sudoers.d/10-niilaa-admin-group" >/dev/null <<EOF
# NIILAA: Controlled sudo access via admin group (CIS/Bangladesh Bank aligned)
%${ADMIN_GROUP} ALL=(ALL) ALL
EOF

sudo chmod 0440 /etc/sudoers.d/10-niilaa-admin-group
sudo chown root:root /etc/sudoers.d/10-niilaa-admin-group

sudo visudo -cf /etc/sudoers
sudo visudo -cf /etc/sudoers.d/10-niilaa-admin-group

We created a minimal, auditable sudo rule in a drop-in file with correct permissions, then validated it. This reduces the risk of locking ourselves out due to a sudoers syntax error.

Finally, we will verify which users are in the admin group.

getent group "$ADMIN_GROUP"

This shows current membership. In enterprise environments, we should keep this group small, named in access reviews, and tied to HR-driven joiner/mover/leaver processes.

Step 8: Make account lock and expiry behavior measurable

Audits often ask: “How do we know accounts are locked when they should be?” We will add a simple, repeatable verification routine that can be run during internal reviews and external assessments.

8.1 Verify password policy defaults

We will confirm the system-wide defaults that affect new accounts.

echo "=== login.defs ==="
grep -E '^s*(PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_WARN_AGE|ENCRYPT_METHOD)b' /etc/login.defs

echo "=== useradd defaults ==="
sudo useradd -D | grep -E 'INACTIVE|EXPIRE'

This confirms the baseline defaults are present and visible.

8.2 Verify effective policy on a specific account

Now we will verify the effective policy on a chosen account. We will first list candidate human accounts, then we will check one explicitly.

echo "=== Candidate human accounts ==="
awk -F: '($3>=1000 && $1!="nobody"){print $1}' /etc/passwd | head -n 20

This provides a safe list to pick from without guessing usernames.

CHECK_USER=$(awk -F: '($3>=1000 && $1!="nobody"){print $1; exit}' /etc/passwd)
echo "Checking user: $CHECK_USER"
sudo chage -l "$CHECK_USER"

This checks the first human account found and prints its effective aging configuration. In production, we should repeat this for privileged users and any accounts in scope for compliance.

Troubleshooting

When account and password policies change, the failures are usually predictable. The key is recognizing the symptom and mapping it to the control we just enforced.

Symptom: Users report “Authentication failure” after a period of inactivity

  • Likely cause: The account became inactive after password expiry due to INACTIVE=30 or per-user chage -I settings.
  • Fix: Confirm account status and reset appropriately.
USER_TO_CHECK=$(awk -F: '($3>=1000 && $1!="nobody"){print $1; exit}' /etc/passwd)
sudo chage -l "$USER_TO_CHECK"
sudo passwd -S "$USER_TO_CHECK" || true

This shows whether the password is expired and whether the account is locked. If business-approved, we can unlock and force a password reset.

# Use only after approval and identity verification
sudo passwd -u "$USER_TO_CHECK"
sudo passwd -e "$USER_TO_CHECK"

This unlocks the account and forces a password change at next login, restoring access while keeping the policy intact.

Symptom: Automation breaks because a service account password expired

  • Likely cause: We applied aging to UID >= 1000 accounts, and the service account lives in that range.
  • Fix: Reclassify the account properly: either move it to a managed secret mechanism (preferred) or set an explicit non-expiring policy with documented exception and compensating controls.
echo "=== Identify service-like accounts in UID>=1000 range (shell and home can hint) ==="
awk -F: '($3>=1000 && $1!="nobody"){print $1":"$3":"$6":"$7}' /etc/passwd | sort | head -n 50

This helps spot service accounts that were created like human users. If an exception is approved, we can set a specific policy for that account and document it.

# Exception pattern (use only with formal approval and documentation)
SERVICE_USER=$(awk -F: '($3>=1000 && $1!="nobody"){print $1; exit}' /etc/passwd)
sudo chage -M 99999 -m 0 -W 14 -I -1 "$SERVICE_USER"
sudo chage -l "$SERVICE_USER"

This demonstrates how an exception would look technically. In regulated environments, exceptions must be rare, time-bound, and paired with compensating controls like key-based auth, restricted sudo, and monitoring.

Symptom: sudo stops working after changes

  • Likely cause: A syntax error in a sudoers file or incorrect permissions on a file in /etc/sudoers.d.
  • Fix: Validate sudoers syntax and permissions from a root session or console access.
sudo ls -l /etc/sudoers /etc/sudoers.d
sudo visudo -cf /etc/sudoers
sudo find /etc/sudoers.d -type f -maxdepth 1 -print -exec sudo visudo -cf {} ;

This checks permissions and validates each sudoers drop-in. Files must typically be owned by root and set to 0440.

Common mistakes

  • Mistake: Setting defaults in /etc/login.defs and assuming existing accounts are covered.
    Symptom: Audit shows many users with “Password expires: never.”
    Fix: Apply chage to existing accounts and verify with chage -l.
  • Mistake: Treating service accounts like human accounts without an ownership model.
    Symptom: Scheduled jobs fail after password expiry; application teams report “random outages.”
    Fix: Identify service accounts, move them to managed secrets where possible, and document any exceptions with compensating controls.
  • Mistake: Editing /etc/sudoers directly without validation.
    Symptom: “sudo: parse error” or complete loss of sudo access.
    Fix: Use /etc/sudoers.d drop-ins and always validate with visudo -cf.
  • Mistake: Ignoring UID 0 drift over time.
    Symptom: More than one UID 0 account appears in scans; accountability gaps in incident response.
    Fix: Lock non-root UID 0 accounts immediately, then remediate properly with ownership review and least privilege.

How do we at NIILAA look at this

This setup is not impressive because it is complex. It is impressive because it is controlled. Every component is intentional. Every configuration has a reason. This is how infrastructure should scale — quietly, predictably, and without drama.

At NIILAA, we help organizations design, deploy, secure, and maintain Linux compliance hardening in production environments—mapping CIS-aligned controls to real operational workflows, aligning with Bangladesh Bank expectations, and building evidence-ready configurations that survive team changes, audits, and growth.

Website: https://www.niilaa.com
Email: [email protected]
LinkedIn: https://www.linkedin.com/company/niilaa
Facebook: https://www.facebook.com/niilaa.llc

Leave A Comment

All fields marked with an asterisk (*) are required