DevOpsil
Linux
93%
Fresh

Linux User & Group Management: From Root to Least Privilege

Aareez AsifAareez Asif27 min read

User management is the first line of defense on any Linux server. Every process runs as some user, every file is owned by some user, and every security breach involves compromising some user's credentials or privileges. Getting this right is foundational to everything else, from container security to compliance audits to incident response. This guide covers every aspect of Linux identity management that matters in production: the underlying files, the commands, the authentication framework, and real-world procedures for onboarding, offboarding, and auditing.

The Identity Files

Linux stores user and group information in a set of plain-text files managed by the C library and shadow utilities. Understanding their exact format is essential for troubleshooting, scripting, and building automation.

/etc/passwd

Every user account on the system, whether human or service, has a line in /etc/passwd. This file is world-readable because many programs need to resolve UIDs to usernames.

aareez:x:1000:1000:Aareez Asif:/home/aareez:/bin/bash
deploy:x:1001:1001:Deploy User:/opt/deploy:/bin/bash
nginx:x:101:101:Nginx Worker:/var/cache/nginx:/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

Each line has seven colon-delimited fields:

FieldExampleDescription
UsernameaareezLogin name, max 32 characters on modern systems. Must start with a lowercase letter or underscore.
PasswordxAn x means the hashed password is stored in /etc/shadow. If this field is empty, no password is required (dangerous).
UID1000Numeric user ID. UID 0 is always root. UIDs 1-999 are reserved for system accounts on most distros.
GID1000Primary group ID. References an entry in /etc/group.
GECOSAareez AsifComment field. Historically stood for General Electric Comprehensive Operating System. Typically holds the full name, sometimes office info separated by commas.
Home Directory/home/aareezAbsolute path to the user's home directory. Does not guarantee the directory actually exists.
Login Shell/bin/bashProgram executed on login. Set to /sbin/nologin or /bin/false to prevent interactive access.

UID ranges by convention:

RangePurpose
0Root superuser
1-99Statically allocated system accounts (distro-defined)
100-999Dynamically allocated system accounts (for packages/services)
1000-60000Regular user accounts
65534nobody (overflow UID)

You can inspect individual entries programmatically with getent:

# Query a specific user (works with local files, LDAP, SSSD, etc.)
getent passwd deploy

# List all users from all configured sources
getent passwd

/etc/shadow

The actual password hashes live in /etc/shadow, which is readable only by root (permissions 0640, owned by root:shadow). This separation exists because /etc/passwd must be world-readable, but password hashes must not be.

aareez:$6$rounds=5000$xK3jR9pQ$kL7mN...truncated...:19440:0:90:7:30:20089:
deploy:!:19440:0:99999:7:::
svc_backup:*:19440:::::

The nine colon-delimited fields:

FieldPositionDescription
Username1Must match an entry in /etc/passwd
Password Hash2Format is $algorithm$salt$hash. $6$ means SHA-512, $y$ means yescrypt (modern default on Debian 12+, Ubuntu 24.04+). A ! prefix means the account is locked. A * means no password has ever been set.
Last Changed3Days since Jan 1 1970 when password was last changed
Min Age4Minimum days before password can be changed again (0 means anytime)
Max Age5Maximum days the password is valid (99999 effectively means never expires)
Warn Period6Days before expiry that the user gets a warning on login
Inactive Period7Days after password expires that the account is still usable (grace period)
Expiration Date8Absolute account expiration date (days since epoch). Empty means never.
Reserved9Unused, reserved for future use

Hash algorithm identifiers:

PrefixAlgorithmStatus
$1$MD5Insecure, do not use
$5$SHA-256Acceptable
$6$SHA-512Widely used, strong
$y$yescryptModern default, memory-hard, strongest
$2b$bcryptUsed on some BSDs and via PAM modules

To convert a days-since-epoch value to a human date:

# Convert shadow date field to readable date
date -d "1970-01-01 + 19440 days" +%Y-%m-%d

/etc/group

Groups are defined in /etc/group. The format is straightforward:

docker:x:999:aareez,deploy
sudo:x:27:aareez
webteam:x:1010:aareez,deploy,jenkins
aareez:x:1000:
FieldDescription
Group NameName of the group
PasswordAlmost always x or empty. Group passwords (set via gpasswd) are rarely used in practice.
GIDNumeric group ID
Member ListComma-separated list of usernames. Note: users whose primary group this is (via /etc/passwd GID field) are NOT listed here.

That last point is a common source of confusion. If aareez has GID 1000 in /etc/passwd, they are a member of group aareez (GID 1000) even though they do not appear in the member list in /etc/group.

/etc/gshadow

The lesser-known /etc/gshadow stores group passwords and group administrator information:

docker:!::aareez,deploy
webteam:!:aareez:aareez,deploy,jenkins

Fields: group name, encrypted password, group admins (can add/remove members), group members. Group admins are users who can manage group membership via gpasswd without needing root.

Creating Users

useradd vs adduser

These two commands serve the same purpose but work differently depending on the distribution.

useradd is the low-level binary (part of the shadow package). It exists on every Linux distribution and does exactly what you tell it, nothing more. Without flags, it may not create a home directory or set a shell.

adduser is a Perl script on Debian/Ubuntu that wraps useradd with sane defaults: it creates the home directory, copies skeleton files from /etc/skel, prompts for a password, and sets the GECOS info interactively. On RHEL/Fedora, adduser is just a symlink to useradd.

# Using useradd (explicit, works everywhere)
sudo useradd -m -s /bin/bash -c "Sarah Chen" -G docker,sudo -e 2027-06-30 schen
sudo passwd schen

# Using adduser on Debian/Ubuntu (interactive, friendlier)
sudo adduser schen
sudo usermod -aG docker,sudo schen

All important useradd flags:

FlagPurposeExample
-mCreate the home directory if it does not exist-m
-MDo NOT create a home directory (override defaults)-M
-d PATHSet home directory to a custom path-d /opt/apps/schen
-s SHELLSet the login shell-s /bin/zsh
-c COMMENTSet the GECOS (comment) field-c "Sarah Chen, Eng"
-g GROUPSet the primary group (by name or GID)-g developers
-G GROUPSSet supplementary groups (comma-separated)-G docker,sudo
-u UIDSpecify UID manually-u 5001
-e DATESet account expiration date (YYYY-MM-DD)-e 2027-12-31
-f DAYSDays after password expires until account is disabled-f 30
-rCreate a system account (UID in system range, no aging info)-r
-k SKELUse an alternative skeleton directory-k /etc/skel-dev
-K KEY=VALUEOverride /etc/login.defs values-K UMASK=077
-oAllow creating a user with a duplicate (non-unique) UID-o -u 0

The skeleton directory (/etc/skel) contains files copied into new home directories. This is where you place default .bashrc, .profile, .vimrc, or SSH config templates:

# See what ships by default
ls -la /etc/skel/

# Add a custom bashrc for all new users
sudo cp /opt/configs/standard.bashrc /etc/skel/.bashrc

Default values for useradd are controlled by /etc/login.defs and /etc/default/useradd:

# View current defaults
useradd -D

# Key settings in /etc/login.defs
# PASS_MAX_DAYS   90
# PASS_MIN_DAYS   1
# PASS_WARN_AGE   14
# UID_MIN         1000
# UID_MAX         60000
# UMASK           077
# CREATE_HOME     yes
# ENCRYPT_METHOD  YESCRYPT

Modifying Users

The usermod command modifies existing accounts. Most flags mirror useradd:

# Add user to supplementary groups
# CRITICAL: always use -a (append). Without -a, the user is removed from all
# groups not listed, which can lock them out of sudo.
sudo usermod -aG docker,webteam schen

# Change login shell
sudo usermod -s /bin/zsh schen

# Change home directory and move existing files
sudo usermod -d /new/home/schen -m schen

# Rename a user account (changes login name only, not home dir or group name)
sudo usermod -l newname oldname

# Change primary group
sudo usermod -g developers schen

# Lock an account (prefixes ! to the password hash in /etc/shadow)
sudo usermod -L schen

# Unlock an account (removes the ! prefix)
sudo usermod -U schen

# Set account expiration
sudo usermod -e 2027-06-30 contractor01

# Change the UID (you will also need to chown their files)
sudo usermod -u 2001 schen
sudo find / -user 1001 -exec chown -h 2001 {} \; 2>/dev/null

The -aG trap: Running sudo usermod -G docker schen without -a replaces all supplementary groups with just docker. If the user was in sudo, they lose sudo access immediately. Always use -aG unless you intentionally want to replace all groups.

To change your own shell without root privileges:

chsh -s /bin/zsh

Valid shells are listed in /etc/shells. The chsh command refuses to set a shell not in that file.

Deleting Users Safely

Removing a user account requires more thought than just running userdel. In production, you need to verify nothing depends on that account first.

# Step 1: Lock the account immediately to prevent further access
sudo usermod -L departed_user

# Step 2: Kill any running processes
sudo pkill -u departed_user
# Or more forcefully:
sudo pkill -9 -u departed_user

# Step 3: Check for cron jobs and at jobs
sudo crontab -l -u departed_user
sudo atq | grep departed_user

# Step 4: Find all files owned by the user
sudo find / -user departed_user -type f 2>/dev/null | tee /tmp/departed_user_files.txt

# Step 5: Back up the home directory
sudo tar czf /backup/departed_user_home.tar.gz /home/departed_user

# Step 6: Reassign ownership of shared project files
sudo find /opt/projects -user departed_user -exec chown sysadmin:webteam {} \;

# Step 7: Remove the user and home directory
sudo userdel -r departed_user

# Step 8: Remove any sudoers drop-in files
sudo rm -f /etc/sudoers.d/departed_user

# Step 9: Remove from any remaining group references
# (userdel handles most of this, but verify)
grep departed_user /etc/group

The userdel flags:

FlagDescription
(none)Remove the user from /etc/passwd, /etc/shadow, /etc/group. Keep home directory.
-rAlso remove the home directory and mail spool
-fForce removal even if the user is logged in (dangerous)

Password Policies

Password Aging with chage

The chage command manages password aging parameters stored in /etc/shadow:

# View all aging info for a user
sudo chage -l schen
# Last password change                : Mar 15, 2026
# Password expires                    : Jun 13, 2026
# Password inactive                   : Jul 13, 2026
# Account expires                     : Jun 30, 2027
# 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

Common chage operations:

# Set maximum password age to 90 days
sudo chage -M 90 schen

# Set minimum age to 1 day (prevents changing password back immediately)
sudo chage -m 1 schen

# Set warning period to 14 days before expiry
sudo chage -W 14 schen

# Set inactive period (grace days after expiry where login is still allowed)
sudo chage -I 30 schen

# Force password change on next login
sudo chage -d 0 schen

# Set absolute account expiration
sudo chage -E 2027-12-31 contractor01

# Remove account expiration
sudo chage -E -1 schen

To apply defaults to all new users, edit /etc/login.defs:

PASS_MAX_DAYS   90
PASS_MIN_DAYS   1
PASS_WARN_AGE   14

Password Complexity with PAM

PAM (covered in more depth below) provides the pam_pwquality module for enforcing password strength rules.

# Install the module
sudo apt install libpam-pwquality    # Debian/Ubuntu
sudo dnf install pam_pwquality       # RHEL/Fedora

Configure in /etc/security/pwquality.conf:

# Minimum password length
minlen = 14

# Require at least 1 digit (negative value = required count)
dcredit = -1

# Require at least 1 uppercase letter
ucredit = -1

# Require at least 1 lowercase letter
lcredit = -1

# Require at least 1 special character
ocredit = -1

# Maximum consecutive identical characters
maxrepeat = 3

# Maximum consecutive characters from the same class (e.g., all digits)
maxclassrepeat = 4

# Reject passwords containing the username
reject_username

# Minimum number of character classes required (digit, upper, lower, special)
minclass = 3

# Number of characters that must differ from the old password
difok = 5

# Enforce even when root is setting the password
enforce_for_root

# Check against a dictionary
dictcheck = 1

# Path to cracklib dictionary (usually auto-detected)
# dictpath = /usr/share/cracklib/pw_dict

The PAM configuration line in /etc/pam.d/common-password (Debian) or /etc/pam.d/system-auth (RHEL):

password requisite pam_pwquality.so retry=3
password [success=1 default=ignore] pam_unix.so obscure use_authtok try_first_pass yescrypt

Group Management

Primary vs Supplementary Groups

Every user has exactly one primary group, set by the GID field in /etc/passwd. When a user creates a file, it is owned by their primary group (unless the directory has the setgid bit set or the filesystem uses a different default). A user can additionally belong to up to 65536 supplementary groups (kernel limit since Linux 2.6.4).

# Check all groups for a user
id schen
# uid=1001(schen) gid=1001(schen) groups=1001(schen),27(sudo),999(docker),1010(webteam)

# Just the group names
groups schen
# schen : schen sudo docker webteam

In the id output, gid=1001(schen) is the primary group. Everything after groups= includes both the primary and all supplementary groups.

User Private Groups (UPG): Most modern distributions create a new group with the same name as the user and assign it as the primary group. This means files created by schen are owned by group schen, not by a shared group like users. This is more secure by default.

groupadd, groupmod, groupdel

# Create a new group
sudo groupadd developers

# Create with a specific GID
sudo groupadd -g 2000 contractors

# Create a system group (GID in system range)
sudo groupadd -r apprunners

# Rename a group
sudo groupmod -n engineering developers

# Change a group's GID (requires updating file ownership afterward)
sudo groupmod -g 2001 contractors
sudo find / -group 2000 -exec chgrp 2001 {} \; 2>/dev/null

# Delete a group (fails if any user has it as their primary group)
sudo groupdel contractors

gpasswd for Group Administration

The gpasswd command manages /etc/group and /etc/gshadow. It is the preferred way to add and remove users from groups:

# Add a user to a group
sudo gpasswd -a schen docker

# Remove a user from a group
sudo gpasswd -d schen docker

# Set group administrators (users who can manage membership without root)
sudo gpasswd -A schen webteam

# Now schen can add/remove members without sudo:
gpasswd -a deploy webteam

# Set a group password (rarely used, allows newgrp without membership)
sudo gpasswd docker

# Remove group password
sudo gpasswd -r docker

Sudo Configuration In Depth

The Sudoers Syntax

The /etc/sudoers file controls who can run what commands as which users. Never edit it with a regular text editor. Always use visudo, which validates syntax before saving. A single syntax error in sudoers can lock everyone out of root access.

sudo visudo

The general format of a sudoers rule:

WHO  WHERE=(AS_WHO) WHAT

Where:

  • WHO is a user, a group prefixed with %, or an alias
  • WHERE is a hostname (usually ALL)
  • AS_WHO is the target user and optionally group to run as (e.g., (root), (ALL:ALL))
  • WHAT is the command(s) allowed
# Full unrestricted sudo for a user
aareez  ALL=(ALL:ALL) ALL

# Allow the webteam group to restart web services without a password
%webteam ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx, \
                              /usr/bin/systemctl reload nginx, \
                              /usr/bin/systemctl restart php8.2-fpm

# Allow deploy to run a specific script as the app user
deploy ALL=(appuser) NOPASSWD: /opt/deploy/scripts/release.sh

# Allow monitoring user to read logs only
monitor ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u *, /usr/bin/tail -f /var/log/*

# Deny a specific command even with full sudo
schen ALL=(ALL) ALL, !/usr/bin/su, !/usr/bin/bash, !/usr/bin/sh

Sudoers Aliases

For complex environments, aliases reduce duplication:

# User aliases
User_Alias ADMINS = aareez, schen
User_Alias DEPLOYERS = deploy, jenkins

# Host aliases
Host_Alias WEBSERVERS = web01, web02, web03
Host_Alias DBSERVERS = db01, db02

# Command aliases
Cmnd_Alias WEB_CMDS = /usr/bin/systemctl restart nginx, \
                       /usr/bin/systemctl reload nginx, \
                       /usr/bin/systemctl restart php8.2-fpm
Cmnd_Alias DEPLOY_CMDS = /opt/deploy/scripts/release.sh, \
                          /opt/deploy/scripts/rollback.sh

# Apply
ADMINS ALL=(ALL:ALL) ALL
DEPLOYERS WEBSERVERS=(appuser) NOPASSWD: DEPLOY_CMDS
%webteam WEBSERVERS=(ALL) NOPASSWD: WEB_CMDS

The /etc/sudoers.d/ Drop-in Directory

For maintainability (especially with automation tools like Ansible), use drop-in files instead of modifying the main sudoers file:

# Create a drop-in file (validated on save)
sudo visudo -f /etc/sudoers.d/deploy-permissions

Contents:

deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp, \
                           /usr/bin/systemctl status myapp, \
                           /usr/bin/journalctl -u myapp

Requirements for drop-in files:

  • Permissions must be 0440
  • File name must not contain a . or end with ~ (these are silently ignored)
  • The main sudoers file must include @includedir /etc/sudoers.d
sudo chmod 0440 /etc/sudoers.d/deploy-permissions

# Validate the file separately
sudo visudo -cf /etc/sudoers.d/deploy-permissions

Sudo Security Hardening

# In /etc/sudoers via visudo:

# Require password every time (disable 15-minute credential caching)
Defaults timestamp_timeout=0

# Log all sudo commands to a dedicated file
Defaults logfile=/var/log/sudo.log

# Require a TTY (prevents running sudo from scripts/cron without a terminal)
Defaults requiretty

# Show a custom lecture on first sudo usage
Defaults lecture=always
Defaults lecture_file=/etc/sudo_lecture

# Set a secure PATH for sudo commands
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# Log input/output for forensics (records full terminal session)
Defaults log_input, log_output
Defaults iolog_dir=/var/log/sudo-io

# Send alerts on failed sudo attempts via syslog
Defaults syslog=auth

Review sudo activity:

# View sudo logs
sudo journalctl -u sudo
grep sudo /var/log/auth.log

# If io logging is enabled, replay a session
sudo sudoreplay /var/log/sudo-io/00/00/01

PAM (Pluggable Authentication Modules)

PAM is the framework that controls how authentication, authorization, session setup, and password changes work on Linux. When a user logs in via SSH, console, or even su, PAM modules are called in sequence according to configuration files in /etc/pam.d/.

PAM Configuration Structure

Each PAM-aware service has a file in /etc/pam.d/. For example, /etc/pam.d/sshd controls SSH login. A PAM file has lines in this format:

TYPE  CONTROL  MODULE  [OPTIONS]

Types:

TypePurpose
authVerify the user's identity (password, token, key)
accountCheck account validity (expiry, time-of-day restrictions, host access)
passwordHandle password changes (complexity, history)
sessionSet up/tear down the login environment (mount home, set limits, log)

Control values:

ControlMeaning
requiredMust succeed, but continue checking other modules (failures reported at the end)
requisiteMust succeed. Fail immediately if it does not.
sufficientIf this succeeds and no prior required module failed, accept immediately.
optionalResult only matters if this is the only module for this type.

Key PAM Modules

ModulePurpose
pam_unixTraditional /etc/shadow password authentication
pam_pwqualityPassword strength enforcement
pam_faillockLock accounts after repeated failed logins
pam_limitsApply per-user resource limits from /etc/security/limits.conf
pam_accessIP/host/network-based login restrictions via /etc/security/access.conf
pam_timeTime-based access restrictions via /etc/security/time.conf
pam_mkhomedirAutomatically create home directories on first login (useful with LDAP)
pam_google_authenticatorTOTP two-factor authentication
pam_sssSSSD integration for LDAP/AD/Kerberos
pam_wheelRestrict su to members of the wheel group

Account Lockout with pam_faillock

Configure in /etc/pam.d/common-auth (Debian) or /etc/pam.d/system-auth (RHEL):

auth required pam_faillock.so preauth silent deny=5 unlock_time=900 audit
auth [success=1 default=bad] pam_unix.so
auth [default=die] pam_faillock.so authfail deny=5 unlock_time=900 audit
auth sufficient pam_faillock.so authsucc deny=5 unlock_time=900

This locks the account for 15 minutes (900 seconds) after 5 failed attempts.

# View failed login attempts for a user
sudo faillock --user schen

# Manually unlock an account
sudo faillock --user schen --reset

Restricting Access by Host/Network

Using pam_access, you can control which users can log in from which hosts. Edit /etc/security/access.conf:

# Allow admins from anywhere
+ : aareez schen : ALL

# Allow webteam only from the office network
+ : webteam : 10.0.0.0/24

# Deny everyone else
- : ALL : ALL

Enable it in the relevant PAM config:

account required pam_access.so

NSS (Name Service Switch)

NSS determines where the system looks up user, group, host, and other information. It is configured in /etc/nsswitch.conf:

passwd:     files systemd
group:      files systemd
shadow:     files
hosts:      files dns mymachines

The passwd: files systemd line means: first check /etc/passwd, then check systemd-userdbd. For LDAP or SSSD integration, you add those sources:

passwd:     files sss
group:      files sss
shadow:     files sss

This is the mechanism that makes getent passwd return both local users and LDAP users. PAM handles authentication (can this user log in?), while NSS handles identity resolution (who is UID 5001?). They are complementary systems.

# Query NSS for a user
getent passwd schen

# Query NSS for a group
getent group developers

# List all groups (local + remote sources)
getent group

LDAP/AD Integration Overview

In enterprise environments, user accounts are typically managed centrally via LDAP (OpenLDAP, FreeIPA) or Microsoft Active Directory. Linux servers join the directory and authenticate users against it, eliminating the need to create local accounts on every machine.

The Modern Approach: SSSD

SSSD (System Security Services Daemon) is the standard way to integrate Linux with LDAP, Active Directory, or FreeIPA. It handles both NSS lookups and PAM authentication, provides offline caching, and supports Kerberos.

# Install SSSD and required packages
sudo apt install sssd sssd-ad sssd-tools realmd adcli  # Debian/Ubuntu
sudo dnf install sssd sssd-ad realmd adcli              # RHEL/Fedora

# Join an Active Directory domain
sudo realm discover example.com
sudo realm join example.com -U admin_user

After joining, SSSD is configured automatically in /etc/sssd/sssd.conf:

[sssd]
domains = example.com
services = nss, pam

[domain/example.com]
id_provider = ad
auth_provider = ad
access_provider = ad
cache_credentials = true
default_shell = /bin/bash
fallback_homedir = /home/%u@%d

# Restrict login to specific AD groups
ad_access_filter = memberOf=CN=LinuxAdmins,OU=Groups,DC=example,DC=com

PAM is updated to include SSSD:

auth sufficient pam_sss.so
account [default=bad success=ok user_unknown=ignore] pam_sss.so
password sufficient pam_sss.so
session optional pam_sss.so

Auto-create home directories on first login:

session required pam_mkhomedir.so skel=/etc/skel umask=077

Verifying the Integration

# Check if an AD user can be resolved
getent passwd jdoe@example.com

# Check their groups
id jdoe@example.com

# Test authentication
sudo su - jdoe@example.com

# Check SSSD status
sudo systemctl status sssd
sudo sssctl domain-status example.com

Practical Scenarios

Scenario 1: Onboarding a New Developer

A complete, production-ready script for adding a new team member:

#!/bin/bash
set -euo pipefail

# Usage: ./onboard.sh username "Full Name" ssh-ed25519-AAAA...
USERNAME="${1:?Usage: $0 username fullname ssh_pubkey}"
FULLNAME="${2:?Provide full name}"
SSH_PUBKEY="${3:?Provide SSH public key}"

# Validate username format
if [[ ! "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
    echo "ERROR: Invalid username format" >&2
    exit 1
fi

# Create the user with appropriate groups
sudo useradd -m -s /bin/bash -c "$FULLNAME" -G docker,webteam "$USERNAME"

# Set up SSH key-based authentication
sudo mkdir -p /home/"$USERNAME"/.ssh
echo "$SSH_PUBKEY" | sudo tee /home/"$USERNAME"/.ssh/authorized_keys > /dev/null
sudo chmod 700 /home/"$USERNAME"/.ssh
sudo chmod 600 /home/"$USERNAME"/.ssh/authorized_keys
sudo chown -R "$USERNAME":"$USERNAME" /home/"$USERNAME"/.ssh

# Set password aging policy
sudo chage -M 90 -m 1 -W 14 -I 30 "$USERNAME"

# Grant limited sudo access for service status commands
cat <<SUDOERS | sudo tee /etc/sudoers.d/"$USERNAME" > /dev/null
$USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl status *, /usr/bin/journalctl -u *
SUDOERS
sudo chmod 0440 /etc/sudoers.d/"$USERNAME"
sudo visudo -cf /etc/sudoers.d/"$USERNAME"

echo "User $USERNAME created successfully."
echo "  - Groups: $(groups "$USERNAME" | cut -d: -f2)"
echo "  - Shell: /bin/bash"
echo "  - SSH key installed"
echo "  - Password aging: max 90 days"

Scenario 2: Revoking Access Immediately

When an employee leaves or an account is compromised, speed matters:

#!/bin/bash
set -euo pipefail

USERNAME="${1:?Usage: $0 username}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/var/backups/offboarded"

echo "=== Revoking access for $USERNAME ==="

# 1. Lock the account immediately
sudo usermod -L "$USERNAME"
sudo usermod -e 1 "$USERNAME"  # Expire the account (date in the past)
echo "[OK] Account locked and expired"

# 2. Kill all running processes
sudo pkill -u "$USERNAME" 2>/dev/null && echo "[OK] Processes terminated" || echo "[--] No running processes"

# 3. Invalidate any existing SSH sessions
# Changing the shell prevents new sessions from spawning
sudo usermod -s /sbin/nologin "$USERNAME"
echo "[OK] Shell set to nologin"

# 4. Remove SSH authorized keys
if [ -f /home/"$USERNAME"/.ssh/authorized_keys ]; then
    sudo mv /home/"$USERNAME"/.ssh/authorized_keys \
            /home/"$USERNAME"/.ssh/authorized_keys.revoked."$TIMESTAMP"
    echo "[OK] SSH keys revoked"
fi

# 5. Remove sudo permissions
if [ -f /etc/sudoers.d/"$USERNAME" ]; then
    sudo rm /etc/sudoers.d/"$USERNAME"
    echo "[OK] Sudoers file removed"
fi

# 6. Remove from all supplementary groups
for grp in $(id -nG "$USERNAME" | tr ' ' '\n' | grep -v "^${USERNAME}$"); do
    sudo gpasswd -d "$USERNAME" "$grp" 2>/dev/null
done
echo "[OK] Removed from all supplementary groups"

# 7. Backup home directory
sudo mkdir -p "$BACKUP_DIR"
sudo tar czf "$BACKUP_DIR/${USERNAME}_${TIMESTAMP}.tar.gz" /home/"$USERNAME" 2>/dev/null
echo "[OK] Home directory backed up to $BACKUP_DIR/${USERNAME}_${TIMESTAMP}.tar.gz"

# 8. Remove any at/cron jobs
sudo crontab -r -u "$USERNAME" 2>/dev/null || true
echo "[OK] Cron jobs removed"

echo "=== Access revoked for $USERNAME ==="
echo "To fully delete the account later: sudo userdel -r $USERNAME"

Scenario 3: Auditing Users and Access

Regular auditing catches stale accounts, overprivileged users, and unauthorized access:

#!/bin/bash
echo "=== User Audit Report $(date) ==="

echo ""
echo "--- Users with UID 0 (root-equivalent) ---"
awk -F: '$3 == 0 {print $1}' /etc/passwd

echo ""
echo "--- Users with login shells (potential interactive accounts) ---"
grep -vE '(/sbin/nologin|/bin/false|/usr/sbin/nologin)' /etc/passwd | \
    awk -F: '{printf "%-20s UID=%-6s Home=%s\n", $1, $3, $6}'

echo ""
echo "--- Users in the sudo/wheel group ---"
getent group sudo wheel 2>/dev/null | cut -d: -f4 | tr ',' '\n' | sort -u

echo ""
echo "--- Accounts that have never logged in ---"
lastlog 2>/dev/null | awk 'NR>1 && /Never logged in/ {print $1}'

echo ""
echo "--- Accounts with empty passwords ---"
sudo awk -F: '($2 == "" ) {print $1}' /etc/shadow

echo ""
echo "--- Accounts with expired passwords ---"
today=$(( $(date +%s) / 86400 ))
sudo awk -F: -v today="$today" \
    '$5 != "" && $5 != 99999 && ($3 + $5) < today {print $1 " (expired)"}' \
    /etc/shadow

echo ""
echo "--- Files in sudoers.d ---"
ls -la /etc/sudoers.d/ 2>/dev/null

echo ""
echo "--- Recent sudo usage (last 50 entries) ---"
grep 'sudo:' /var/log/auth.log 2>/dev/null | tail -50 || \
    journalctl _COMM=sudo --no-pager -n 50 2>/dev/null

Run this weekly via cron and pipe the output to your monitoring system or email.

System Accounts and Service Users

System accounts exist to run services, not for human login. Creating them properly is important for security isolation:

# Create a system account for a service
sudo useradd -r -s /sbin/nologin -d /opt/myapp -c "MyApp Service" myapp

# Verify
id myapp
# uid=998(myapp) gid=998(myapp) groups=998(myapp)

grep myapp /etc/passwd
# myapp:x:998:998:MyApp Service:/opt/myapp:/sbin/nologin

Key characteristics:

  • UID below 1000 (system range)
  • Shell set to /sbin/nologin or /bin/false
  • No home directory created by default (use -m only if the service needs one)
  • No password aging information

In systemd unit files, reference the service user:

[Service]
User=myapp
Group=myapp
LimitNOFILE=65535
LimitNPROC=4096
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/data

User Resource Limits

Persistent Limits via limits.conf

Edit /etc/security/limits.conf or add files in /etc/security/limits.d/:

# /etc/security/limits.d/90-production.conf

# Deploy user needs high file descriptor limits
deploy  soft  nofile  65535
deploy  hard  nofile  65535
deploy  soft  nproc   4096
deploy  hard  nproc   4096

# Disable core dumps for all users (security measure)
*       soft  core    0
*       hard  core    0

# Limit memory for untrusted users
contractor01  hard  as  2097152   # 2GB address space limit

Note: for systemd services, limits in limits.conf do not apply. Set them in the unit file with LimitNOFILE=, LimitNPROC=, etc.

SSH Key-Based Authentication

Password authentication over SSH is a liability on any internet-facing server. Switch to key-based auth:

# Generate a key pair (Ed25519 is recommended)
ssh-keygen -t ed25519 -C "schen@workstation"

# Copy the public key to the server
ssh-copy-id -i ~/.ssh/id_ed25519.pub schen@server.example.com

Then harden /etc/ssh/sshd_config:

PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
AuthorizedKeysFile .ssh/authorized_keys
MaxAuthTries 3
LoginGraceTime 30
AllowGroups ssh-users admins

Reload SSH:

sudo systemctl reload sshd

The AllowGroups directive is powerful: only users in the listed groups can SSH in at all, regardless of having valid keys or passwords.

Security Best Practices

Account Hygiene:

  • Run lastlog weekly to identify accounts that have never logged in or have not logged in recently. Disable them.
  • Set expiration dates for contractor and temporary accounts at creation time.
  • Use User Private Groups (the default on most distros) so file permissions are not accidentally shared.
  • Never use shared accounts. Every human gets their own account for auditability.

Password and Authentication:

  • Enforce minimum 14-character passwords with pam_pwquality.
  • Set PASS_MAX_DAYS to 90 in /etc/login.defs for new accounts.
  • Use pam_faillock to lock accounts after 5 failed attempts.
  • Disable password authentication over SSH entirely. Use Ed25519 keys.
  • Implement two-factor authentication for privileged accounts using pam_google_authenticator or hardware tokens.

Privilege Management:

  • Follow the principle of least privilege. Give users the minimum sudo access they need, not ALL=(ALL) ALL.
  • Use NOPASSWD only for specific commands, never for blanket access.
  • Put sudo rules in /etc/sudoers.d/ files named after the user or role for easy auditing and removal.
  • Enable sudo I/O logging (Defaults log_input, log_output) on servers handling sensitive data.
  • Restrict su to the wheel group via pam_wheel.

System Accounts:

  • Always use /sbin/nologin as the shell for service accounts.
  • Create service accounts with useradd -r so they get UIDs in the system range.
  • Apply systemd hardening options (NoNewPrivileges, ProtectSystem, ProtectHome) to service units.

Auditing:

  • Audit /etc/sudoers.d/ contents regularly. Stale files from former employees are an open door.
  • Monitor /var/log/auth.log or journalctl -u sshd for failed login attempts.
  • Use auditd rules to log changes to /etc/passwd, /etc/shadow, and /etc/sudoers:
# /etc/audit/rules.d/identity.rules
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k identity
-w /etc/sudoers.d/ -p wa -k identity

Reload auditd rules:

sudo augenrules --load

Search for identity-related audit events:

sudo ausearch -k identity --start today

Network-Level Controls:

  • Use pam_access to restrict which users can log in from which networks.
  • Use AllowGroups in sshd_config to limit SSH access to specific groups.
  • For multi-server environments, centralize authentication with SSSD and LDAP/AD rather than managing local accounts everywhere.

The principle of least privilege should guide every decision. Give users and services only the permissions they need, use nologin shells for service accounts, prefer granular sudo rules over shared root passwords, and enforce SSH keys over password auth. Audit your user list regularly, because stale accounts are an open invitation. Run lastlog and last periodically to spot accounts that should have been removed months ago. In production, user management is not a set-and-forget task. It is an ongoing operational discipline.

Share:
Aareez Asif
Aareez Asif

Senior Kubernetes Architect

10+ years orchestrating containers in production. Battle-tested opinions on everything from pod scheduling to service mesh. I've seen clusters burn and helped rebuild them better.

Related Articles

LinuxQuick RefFresh

Linux Networking Commands: Cheat Sheet

Linux networking commands cheat sheet for troubleshooting — interfaces, routing, DNS lookups, connections, iptables firewalls, and tcpdump packet capture.

Aareez Asif·
3 min read