Linux User & Group Management: From Root to Least Privilege
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:
| Field | Example | Description |
|---|---|---|
| Username | aareez | Login name, max 32 characters on modern systems. Must start with a lowercase letter or underscore. |
| Password | x | An x means the hashed password is stored in /etc/shadow. If this field is empty, no password is required (dangerous). |
| UID | 1000 | Numeric user ID. UID 0 is always root. UIDs 1-999 are reserved for system accounts on most distros. |
| GID | 1000 | Primary group ID. References an entry in /etc/group. |
| GECOS | Aareez Asif | Comment field. Historically stood for General Electric Comprehensive Operating System. Typically holds the full name, sometimes office info separated by commas. |
| Home Directory | /home/aareez | Absolute path to the user's home directory. Does not guarantee the directory actually exists. |
| Login Shell | /bin/bash | Program executed on login. Set to /sbin/nologin or /bin/false to prevent interactive access. |
UID ranges by convention:
| Range | Purpose |
|---|---|
| 0 | Root superuser |
| 1-99 | Statically allocated system accounts (distro-defined) |
| 100-999 | Dynamically allocated system accounts (for packages/services) |
| 1000-60000 | Regular user accounts |
| 65534 | nobody (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:
| Field | Position | Description |
|---|---|---|
| Username | 1 | Must match an entry in /etc/passwd |
| Password Hash | 2 | Format 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 Changed | 3 | Days since Jan 1 1970 when password was last changed |
| Min Age | 4 | Minimum days before password can be changed again (0 means anytime) |
| Max Age | 5 | Maximum days the password is valid (99999 effectively means never expires) |
| Warn Period | 6 | Days before expiry that the user gets a warning on login |
| Inactive Period | 7 | Days after password expires that the account is still usable (grace period) |
| Expiration Date | 8 | Absolute account expiration date (days since epoch). Empty means never. |
| Reserved | 9 | Unused, reserved for future use |
Hash algorithm identifiers:
| Prefix | Algorithm | Status |
|---|---|---|
$1$ | MD5 | Insecure, do not use |
$5$ | SHA-256 | Acceptable |
$6$ | SHA-512 | Widely used, strong |
$y$ | yescrypt | Modern default, memory-hard, strongest |
$2b$ | bcrypt | Used 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:
| Field | Description |
|---|---|
| Group Name | Name of the group |
| Password | Almost always x or empty. Group passwords (set via gpasswd) are rarely used in practice. |
| GID | Numeric group ID |
| Member List | Comma-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:
| Flag | Purpose | Example |
|---|---|---|
-m | Create the home directory if it does not exist | -m |
-M | Do NOT create a home directory (override defaults) | -M |
-d PATH | Set home directory to a custom path | -d /opt/apps/schen |
-s SHELL | Set the login shell | -s /bin/zsh |
-c COMMENT | Set the GECOS (comment) field | -c "Sarah Chen, Eng" |
-g GROUP | Set the primary group (by name or GID) | -g developers |
-G GROUPS | Set supplementary groups (comma-separated) | -G docker,sudo |
-u UID | Specify UID manually | -u 5001 |
-e DATE | Set account expiration date (YYYY-MM-DD) | -e 2027-12-31 |
-f DAYS | Days after password expires until account is disabled | -f 30 |
-r | Create a system account (UID in system range, no aging info) | -r |
-k SKEL | Use an alternative skeleton directory | -k /etc/skel-dev |
-K KEY=VALUE | Override /etc/login.defs values | -K UMASK=077 |
-o | Allow 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:
| Flag | Description |
|---|---|
| (none) | Remove the user from /etc/passwd, /etc/shadow, /etc/group. Keep home directory. |
-r | Also remove the home directory and mail spool |
-f | Force 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
sudoersfile 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:
| Type | Purpose |
|---|---|
auth | Verify the user's identity (password, token, key) |
account | Check account validity (expiry, time-of-day restrictions, host access) |
password | Handle password changes (complexity, history) |
session | Set up/tear down the login environment (mount home, set limits, log) |
Control values:
| Control | Meaning |
|---|---|
required | Must succeed, but continue checking other modules (failures reported at the end) |
requisite | Must succeed. Fail immediately if it does not. |
sufficient | If this succeeds and no prior required module failed, accept immediately. |
optional | Result only matters if this is the only module for this type. |
Key PAM Modules
| Module | Purpose |
|---|---|
pam_unix | Traditional /etc/shadow password authentication |
pam_pwquality | Password strength enforcement |
pam_faillock | Lock accounts after repeated failed logins |
pam_limits | Apply per-user resource limits from /etc/security/limits.conf |
pam_access | IP/host/network-based login restrictions via /etc/security/access.conf |
pam_time | Time-based access restrictions via /etc/security/time.conf |
pam_mkhomedir | Automatically create home directories on first login (useful with LDAP) |
pam_google_authenticator | TOTP two-factor authentication |
pam_sss | SSSD integration for LDAP/AD/Kerberos |
pam_wheel | Restrict 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/nologinor/bin/false - No home directory created by default (use
-monly 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
lastlogweekly 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_DAYSto 90 in/etc/login.defsfor new accounts. - Use
pam_faillockto 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_authenticatoror hardware tokens.
Privilege Management:
- Follow the principle of least privilege. Give users the minimum sudo access they need, not
ALL=(ALL) ALL. - Use
NOPASSWDonly 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
suto thewheelgroup viapam_wheel.
System Accounts:
- Always use
/sbin/nologinas the shell for service accounts. - Create service accounts with
useradd -rso 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.logorjournalctl -u sshdfor failed login attempts. - Use
auditdrules 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_accessto restrict which users can log in from which networks. - Use
AllowGroupsinsshd_configto 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.
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
Linux Networking Commands: Cheat Sheet
Linux networking commands cheat sheet for troubleshooting — interfaces, routing, DNS lookups, connections, iptables firewalls, and tcpdump packet capture.
Linux Fundamentals: File System Navigation and Permissions
Navigate the Linux file system hierarchy, master essential commands, understand file permissions and ownership, and work with links, pipes, and redirection.
Linux Networking & Firewall: Configuration and Troubleshooting
Configure Linux network interfaces, set up iptables/nftables/firewalld rules, troubleshoot connectivity with ss, ip, dig, and tcpdump, and secure your servers.