How Linux Represents Users and Groups
Every process on Linux runs as a UID. Every file has an owner UID and a group GID. The kernel does not care about usernames - /etc/passwd maps human-readable names to numeric IDs at the application layer. This matters because if you delete a user without cleaning up their files, those files retain the original UID, and if you later create a new user who gets that same UID (common when UIDs are assigned sequentially), they silently inherit access to the old user's files.
UIDs below 1000 are conventionally reserved for system accounts on most modern distributions. On RHEL and its derivatives, that threshold is also 1000. On older Debian systems you may see 500 as the boundary. Check your /etc/login.defs to confirm the UID_MIN value on any system you manage.
Groups serve two purposes: primary groups, which determine the default group ownership of new files created by a user, and supplementary groups, which extend a user's access without changing file ownership semantics. A developer in the docker group gets access to the Docker socket without needing root. That group membership is supplementary.
grep UID_MIN /etc/login.defs
# typical output on Debian/Ubuntu:
# UID_MIN 1000
id username
# uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),27(sudo),998(docker)
adduser vs useradd: Pick One and Know Why
useradd is the low-level binary available on every Linux distribution. adduser is a higher-level Perl or shell wrapper available on Debian-based systems that applies sane defaults from /etc/adduser.conf and interactively prompts for a password. On RHEL/Rocky/AlmaLinux, adduser is just a symlink to useradd.
For interactive administration on Debian/Ubuntu servers, adduser is faster to use correctly. For scripting and automation across distributions, useradd with explicit flags is the only reliable choice.
The critical flags for useradd are -m (create home directory), -s (set shell), -G (supplementary groups), and -c (comment/GECOS field). If you omit -m on systems where CREATE_HOME is not set to yes in /etc/login.defs, no home directory is created, which breaks SSH key auth immediately.
Service accounts that should never log in interactively should always get /usr/sbin/nologin or /bin/false as their shell, and should never have a password set. A service account with a valid password and an interactive shell is a lateral movement opportunity.
# Create an interactive user account
useradd -m -s /bin/bash -G sudo,docker -c "Jane Doe" jdoe
passwd jdoe
# Create a non-interactive service account
useradd -r -s /usr/sbin/nologin -d /var/lib/myapp -c "MyApp Service" myapp
# On Debian/Ubuntu using adduser wrapper
adduser --ingroup developers jdoe
# Verify
getent passwd jdoe
id jdoe
Modifying Existing Accounts with usermod
usermod handles post-creation changes. The two operations you will use most are adding a user to a supplementary group and locking or unlocking an account.
The -aG flag is critical: without -a, usermod replaces the user's supplementary groups entirely rather than appending. Forgetting -a is a classic mistake that strips a developer of docker or sudo access and then you spend ten minutes diagnosing why their commands suddenly fail.
Locking an account with -L prepends an exclamation mark to the password hash in /etc/shadow, preventing password authentication while leaving SSH key auth intact unless you also expire the account with -e. This matters when offboarding a contractor - you want to disable password login immediately but may need to preserve their SSH access for one more day while they hand off work. Use usermod -L for the first and chage -E 0 for immediate full expiration.
To rename a user account without losing their UID (and therefore file ownership), use usermod -l newname oldname combined with usermod -d /home/newname -m to relocate the home directory. This is safer than deleting and recreating when the user has crontabs or owned files scattered across the system.
# Add user to group without removing existing group memberships
usermod -aG docker jdoe
# Lock password auth (keeps SSH key auth working)
usermod -L jdoe
# Full account expiration
chage -E 0 jdoe
# Rename account and relocate home directory
usermod -l janedoe jdoe
usermod -d /home/janedoe -m janedoe
# Unlock a previously locked account
usermod -U jdoe
chmod: The Octal Model and When to Use Symbolic Mode
chmod accepts two syntaxes: octal and symbolic. Experienced sysadmins default to octal for most operations because it is absolute and unambiguous. Symbolic mode (u+x, g-w, o=r) is genuinely useful when you need to modify permissions without knowing or disturbing the current state - for example, adding execute permission to a file regardless of what the other bits currently are.
The octal values map directly to the rwx bits: r=4, w=2, x=1. Three octal digits cover owner, group, and other. A fourth leading digit covers setuid (4), setgid (2), and sticky bit (1).
The setuid bit on executables causes them to run as the file's owner rather than the calling user. This is how passwd can write to /etc/shadow without running as root - actually, passwd runs as root because it is setuid root. Every setuid binary is an attack surface. Audit them regularly.
Setgid on a directory causes new files created inside to inherit the directory's group rather than the creator's primary group. This is the correct way to set up shared project directories. Without it, Alice creates files owned by group alice, Bob can't write to them even if both are in the project group.
The sticky bit on directories (the canonical example being /tmp) prevents users from deleting files they do not own, even if they have write permission on the directory. Use it on any shared writable directory.
# Set standard permissions: owner rwx, group rx, other nothing
chmod 750 /opt/myapp/bin/server
# Setgid on a shared project directory
chmod 2775 /srv/projects/shared
# Sticky bit on a shared writable directory
chmod 1777 /tmp/shared-uploads
# Symbolic: add execute for owner only, whatever other bits exist
chmod u+x deploy.sh
# Symbolic: remove write from group and other
chmod go-w sensitive.conf
# Audit all setuid binaries on the system
find / -xdev -perm -4000 -type f 2>/dev/null | sort
chown and chgrp: Ownership Semantics
File ownership determines which permission set applies to you when you access a file. If you are the owner, the owner bits apply. If you are in the file's group but not the owner, the group bits apply. If neither, the other bits apply. The kernel checks these in order and stops at the first match - being in the file's group does not help you if you are also the owner and the owner bits are more restrictive.
chown can set both owner and group in a single operation using the colon syntax: chown user:group file. Using just chown user:file without specifying a group changes only the owner. Using chown :group file changes only the group, equivalent to chgrp.
The -R flag on chown is useful but requires care. Recursively chowning a directory tree during an application deployment is common, but blindly running chown -R www-data:www-data /var/www on a directory that contains symlinks pointing outside the tree can change ownership of files you did not intend to touch. The --no-dereference flag (-h) on chown applies the ownership change to the symlink itself rather than following it.
For large directory trees in production, chown -R can generate significant inode load. On systems with millions of files, this operation can take minutes and saturate disk I/O. Schedule it during maintenance windows or use find with -exec to batch the changes.
# Set owner and group together
chown deploy:www-data /var/www/app
# Recursive chown for an application directory
chown -R myapp:myapp /opt/myapp
# Change only group
chgrp developers /srv/projects/shared
# Safe recursive chown that does not follow symlinks
chown -hR myapp:myapp /opt/myapp
# Find files owned by a deleted UID (e.g., 1003) and reassign
find / -xdev -uid 1003 -exec chown newowner:newgroup {} \;
sudo Configuration: sudoers Syntax and Common Mistakes
The /etc/sudoers file is parsed by visudo, which validates syntax before writing. Never edit it directly with a text editor - a syntax error in sudoers can lock every user out of sudo on the system. If that happens on a system without a root password set (common on Ubuntu-style deployments), you need console access or a recovery boot.
The basic sudoers entry format is: user host=(runas_user:runas_group) commands. The ALL keyword in any position is a wildcard. Understanding what each ALL means is essential.
Defaults lines control sudo behavior globally or per-user. The most important for security is requiretty, which requires sudo to be run from a real TTY. This breaks some automation but prevents certain privilege escalation attacks. In practice, most DevOps teams disable this for service accounts while keeping it for interactive users.
NEWPASSWD versus NOPASSWD is often confused. NOPASSWD:ALL eliminates password prompts for all commands. This is appropriate for CI/CD service accounts that need specific elevated operations - but scope it to specific commands, not ALL. A deploy account that can run sudo /usr/bin/systemctl restart myapp is very different from one with NOPASSWD:ALL.
The /etc/sudoers.d/ directory allows drop-in files, which is the correct approach for configuration management tools like Ansible, Puppet, or Chef. Drop-in files let you manage sudo grants for specific services without touching the main sudoers file, making auditing and rollback straightforward. Teams using DevOps automation platforms like taskbotshub.ai can template these sudoers drop-ins and deploy them as part of their user provisioning pipelines rather than managing them manually per host.
# Always edit with visudo
visudo
# Or edit a drop-in file
visudo -f /etc/sudoers.d/deploy
# Example sudoers.d/deploy content:
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/systemctl status myapp
# Give a user full sudo with password
jdoe ALL=(ALL:ALL) ALL
# Allow a group to run specific commands without password
%operators ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx
# Verify sudoers syntax without applying
visudo -c
# List what a user can sudo (run as that user or with -U as root)
sudo -l -U jdoe
Sudo Aliases and the Cmnd_Alias Pattern
Cmnd_Alias lets you define named command groups in sudoers, making the file readable and maintainable when you have more than a handful of rules. Without aliases, a sudoers file for a team of 20 managing 15 services becomes unreadable quickly.
The same pattern applies to User_Alias and Runas_Alias. User_Alias groups users or groups under a named alias. This lets you write one rule that covers all members of the ops team without repeating individual usernames.
One important detail: command paths in sudoers must be absolute, and arguments matter. If you write NOPASSWD: /usr/bin/systemctl, the user can run systemctl with any arguments - including systemctl disable sshd or systemctl stop firewalld. Always specify the full command including arguments when the allowed scope is narrow.
Sudoers does not support glob matching for arguments in a straightforward way. If you need to allow a user to restart any service starting with myapp-, you either enumerate them or accept the broader permission. Some teams solve this by wrapping the privileged operation in a custom script owned by root with the setuid bit - that script then validates arguments internally before executing. This approach also gives you a clean audit trail since the wrapper script can log to syslog.
# In /etc/sudoers or a drop-in file:
User_Alias OPS_TEAM = alice, bob, %sre-group
Cmnd_Alias NGINX_CMDS = /usr/bin/systemctl reload nginx, /usr/sbin/nginx -t
Cmnd_Alias SYSTEMD_RESTART = /usr/bin/systemctl restart myapp-api, /usr/bin/systemctl restart myapp-worker
OPS_TEAM ALL=(root) NOPASSWD: NGINX_CMDS, SYSTEMD_RESTART
# Dangerous: allows any systemctl argument
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl
# Safer: only specific operations
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp
Auditing User Activity and Access
Knowing who logged in, when, and what they did with elevated privileges is a compliance requirement on most production systems. Linux provides several audit points out of the box.
last reads /var/log/wtmp and shows login history for all users. lastb reads /var/log/btmp and shows failed login attempts. These files are binary and rotate, so pipe through grep or awk for specific users, and check your log rotation config to ensure btmp is not growing unbounded on systems with brute-force SSH attempts.
For sudo specifically, all sudo invocations are logged to /var/log/auth.log on Debian/Ubuntu systems and /var/log/secure on RHEL-based systems. The log line includes timestamp, user, TTY, and the command executed. Piping this through grep sudo gives you an immediate audit trail.
For deeper auditing, the Linux Audit subsystem (auditd) can track file access, system calls, and user actions at the kernel level. Adding a rule to audit writes to /etc/passwd and /etc/shadow is a minimal baseline on any security-conscious system.
When naming service accounts and system users, using consistent, descriptive names matters for audit log readability. A service account named svc-nginx-prod-01 is immediately identifiable in logs; one named user3 is not. If you are also naming related infrastructure like subdomains or project identifiers, consistent naming conventions extend beyond the OS - tools like nicename.me help validate that a proposed name is available and sensible before you commit to it across DNS, service accounts, and configuration management.
# Show login history for a specific user
last jdoe | head -20
# Show failed login attempts
lastb | grep -v 'btmp begins' | head -20
# Audit sudo usage from auth log (Debian/Ubuntu)
grep sudo /var/log/auth.log | grep COMMAND | tail -50
# Audit sudo usage (RHEL/Rocky)
grep sudo /var/log/secure | grep COMMAND | tail -50
# auditd rule: monitor changes to passwd and shadow
auditctl -w /etc/passwd -p wa -k user-management
auditctl -w /etc/shadow -p wa -k user-management
# Persist auditd rules (RHEL-based)
echo '-w /etc/passwd -p wa -k user-management' >> /etc/audit/rules.d/user-management.rules
echo '-w /etc/shadow -p wa -k user-management' >> /etc/audit/rules.d/user-management.rules
augenrules --load
Deleting Users and Cleaning Up Properly
userdel without flags removes the user from /etc/passwd and /etc/group but leaves the home directory and mail spool intact. userdel -r removes both. On production systems, take a deliberate approach: archive the home directory first, verify no running processes are owned by the user, then remove.
Processes owned by a user do not automatically terminate when the user is deleted. If a service is running as that user and you delete the account, the process continues running with the now-orphaned UID until it exits or is killed. This is usually harmless but confusing in process listings and audit logs.
Crontabs are stored in /var/spool/cron/crontabs/ (Debian) or /var/spool/cron/ (RHEL) under the username. userdel -r does not always remove these. Check and remove manually.
After deletion, the lingering files problem: find / -xdev -nouser 2>/dev/null will find all files whose owner UID no longer exists in /etc/passwd. Run this after every user deletion on systems with compliance requirements. The -xdev flag prevents crossing filesystem boundaries, which matters on systems with NFS mounts.
# Check for running processes before deletion
ps aux | grep username
# Archive home directory before removal
tar -czf /archive/username-$(date +%Y%m%d).tar.gz /home/username
# Remove user and home directory
userdel -r username
# Remove crontab manually if not cleaned up
rm -f /var/spool/cron/crontabs/username
# Find orphaned files after user deletion
find / -xdev -nouser 2>/dev/null | sort
# Find orphaned group ownership
find / -xdev -nogroup 2>/dev/null | sort
Password Policy Enforcement with chage and PAM
chage manages password aging for individual accounts. The key fields are -M (maximum days before password must change), -m (minimum days between changes, which prevents users from immediately changing back to their old password), -W (warning days before expiry), and -E (account expiration date as YYYY-MM-DD or a days-since-epoch integer).
System-wide defaults are in /etc/login.defs: PASS_MAX_DAYS, PASS_MIN_DAYS, and PASS_WARN_AGE. These apply only to newly created accounts - changing them does not retroactively update existing accounts. Run chage against existing accounts if you are tightening policy on a running system.
PAM (Pluggable Authentication Modules) handles the actual password quality enforcement. On RHEL-based systems, pam_pwquality.so replaces the older pam_cracklib.so. The configuration in /etc/security/pwquality.conf sets minimum length, character class requirements, and dictionary check behavior. Setting minlen = 14 and requiring at least two character classes is a reasonable baseline that satisfies most compliance frameworks without being operationally hostile.
For SSH key-based authentication, password policy is largely irrelevant for day-to-day access, but account expiration (chage -E) still gates interactive logins. A user with an expired account cannot authenticate even with a valid SSH key, which is the correct behavior for offboarding.
# Show password aging info for a user
chage -l jdoe
# Set password to expire in 90 days, warn 14 days before
chage -M 90 -W 14 jdoe
# Set account to expire on a specific date
chage -E 2026-12-31 contractor-alice
# Immediately expire password (force change on next login)
chage -d 0 jdoe
# System-wide defaults (new accounts only)
grep -E 'PASS_MAX|PASS_MIN|PASS_WARN' /etc/login.defs
# Check pam_pwquality config (RHEL/Rocky/AlmaLinux)
cat /etc/security/pwquality.conf | grep -v '^#' | grep -v '^$'