sysadmin · Apr 9, 2026 · 14 min read

User Management on RHEL 9

Going deep on every aspect of user and group management on RHEL 9. Not just the commands, but what they actually do under the hood.

// what we’re getting into
  1. What happens when you create a user
  2. The useradd flags that matter
  3. Managing passwords properly
  4. System-wide password policies
  5. The skeleton directory. What new users get.
  6. Groups. Primary vs supplementary.
  7. Locking and disabling accounts
  8. Service accounts. Users that should never log in.
  9. Deleting users properly
  10. Sudo configuration deep dive
  11. Practical exercises
  12. Final checklist: monthly access review

The scenario: You’re managing a RHEL 9 server for a growing team. New people join, people leave, roles change, contractors need temporary access, service accounts need to exist but never log in, and someone always ends up with more permissions than they should have. User management sounds basic until you’re the one responsible for making sure the right people have the right access and nobody else does.

The problem: Most tutorials cover useradd and passwd and call it a day. But real user management is deeper than that. It’s password policies, account expiration, skeleton directories, group hierarchies, sudo scoping, locking accounts without deleting them, and understanding what happens under the hood when you create a user. If you don’t understand the system, you end up with orphaned files, security gaps, and that one contractor account from six months ago that still has sudo access.

What we’re going to do: Go deep on every aspect of user and group management on RHEL 9. Not just the commands but what they actually do to the system, which files they modify, and why each option exists. By the end you’ll manage users like someone who understands the machinery, not someone who memorized a cheat sheet.

What happens when you create a user

When you run useradd jmorales, it feels like one simple thing happened. A user was created. But under the hood, the system touched at least five different files and created a directory. Understanding this is the difference between managing users and actually knowing what you’re doing.

bash
sudo useradd -m -s /bin/bash jmorales

Here’s everything that just happened:

1. /etc/passwd got a new line. This file stores basic user info. One line per user. No passwords here despite the name.

bash
grep jmorales /etc/passwd
# jmorales:x:1001:1001::/home/jmorales:/bin/bash

Let me break down each field separated by colons:

/etc/passwd fields
jmorales       # username
x              # password placeholder (actual hash is in /etc/shadow)
1001           # UID (user ID number)
1001           # GID (primary group ID number)
               # GECOS field (comment/full name, empty here)
/home/jmorales # home directory
/bin/bash      # login shell

2. /etc/shadow got a new line. This is where the actual password hash lives. Only root can read this file.

bash
sudo grep jmorales /etc/shadow
# jmorales:!!:19837:0:99999:7:::

The !! means no password has been set yet. The user exists but can’t log in until you set a password. The other fields control password aging (minimum days, maximum days, warning period, etc.). We’ll dig into those later.

3. /etc/group got a new line. A private group was created with the same name as the user.

bash
grep jmorales /etc/group
# jmorales:x:1001:

This is called the User Private Group (UPG) scheme. Every user gets their own group by default. This matters for file creation permissions, which we’ll cover later.

4. /home/jmorales was created. The home directory was populated with files from /etc/skel.

bash
ls -la /home/jmorales/
# drwx------. 2 jmorales jmorales  62 Apr 24 10:00 .
# -rw-r--r--. 1 jmorales jmorales  18 Apr 24 10:00 .bash_logout
# -rw-r--r--. 1 jmorales jmorales 141 Apr 24 10:00 .bash_profile
# -rw-r--r--. 1 jmorales jmorales 492 Apr 24 10:00 .bashrc

5. /etc/gshadow got a new line. This stores group password info (rarely used but it’s there).

Five files, one directory, one command. Now you know what useradd actually does.

The useradd flags that matter

The bare useradd username command works but it often doesn’t give you what you need. Here are the flags you’ll use in real life and on the RHCSA:

bash
# Full-featured user creation
sudo useradd \
  -m \                    # create home directory
  -s /bin/bash \          # set login shell
  -G developers,wheel \   # supplementary groups
  -c "Juan Morales" \     # full name (GECOS field)
  -e 2026-12-31 \         # account expiration date
  -u 2001 \               # specific UID
  jmorales

Let me explain each one:

-m creates the home directory. On RHEL 9 this is the default behavior, but some systems don’t create it automatically. Always include it to be safe.

-s /bin/bash sets the login shell. Without this, the default is whatever’s in /etc/default/useradd, which might be /bin/sh or even nothing. For service accounts that should never log in, use -s /sbin/nologin.

-G developers,wheel adds the user to supplementary groups. Capital G. This is different from -g (lowercase) which sets the primary group. The primary group is created automatically with the UPG scheme. Supplementary groups are for shared access.

-c “Juan Morales” sets the GECOS field. This is the comment/description that shows up in /etc/passwd and when someone runs finger jmorales. It’s just metadata but it helps when you’re managing dozens of accounts and can’t remember who “jmorales” is.

-e 2026-12-31 sets an account expiration date. After this date, the user cannot log in. Perfect for contractors or temporary access. The account still exists but it’s locked.

-u 2001 sets a specific UID instead of letting the system auto-assign one. Use this when you need UIDs to match across multiple servers (NFS, for example, relies on matching UIDs).

Managing passwords properly

Setting passwords is straightforward. Managing them properly is where it gets interesting.

bash
# Set a password interactively
sudo passwd jmorales

# Set a password non-interactively (for scripts)
echo "jmorales:TempPass123!" | sudo chpasswd

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

# Check password aging info
sudo chage -l jmorales

The chage command is the one most people don’t know about. It controls all the password aging policies:

bash
# Password must be changed at least every 90 days
sudo chage -M 90 jmorales

# Password can't be changed more than once per day (prevents cycling)
sudo chage -m 1 jmorales

# Warn user 14 days before password expires
sudo chage -W 14 jmorales

# Account expires on a specific date
sudo chage -E 2026-12-31 jmorales

# Check all settings
sudo chage -l jmorales

The output of chage -l shows you everything:

chage -l output
Last password change                    : Apr 24, 2026
Password expires                        : Jul 23, 2026
Password inactive                       : never
Account expires                         : Dec 31, 2026
Minimum number of days between changes  : 1
Maximum number of days between changes  : 90
Number of days of warning before expiry : 14
note: Setting -m 1 (minimum 1 day between changes) prevents users from cycling through password changes to get back to their old password. If the system remembers the last 5 passwords and the minimum is 0, a clever user can change their password 5 times in a row and then set it back to the original. Setting a minimum of 1 day stops that.

System-wide password policies

The chage command sets policies per user. For system-wide defaults that apply to every new user created, edit /etc/login.defs:

bash
sudo vim /etc/login.defs

The key lines:

/etc/login.defs
PASS_MAX_DAYS   90      # password expires after 90 days
PASS_MIN_DAYS   1       # must wait 1 day between changes
PASS_MIN_LEN    12      # minimum password length
PASS_WARN_AGE   14      # warn 14 days before expiry
UID_MIN         1000    # regular users start at UID 1000
UID_MAX         60000   # max UID for regular users
CREATE_HOME     yes     # always create home directory

These defaults apply to every user created AFTER you change this file. Existing users keep their current settings unless you update them individually with chage.

The skeleton directory. What new users get.

When useradd -m creates a home directory, it copies everything from /etc/skel into it. This is the skeleton directory. Whatever you put in here, every new user gets.

bash
ls -la /etc/skel/
# .bash_logout
# .bash_profile
# .bashrc

Want every new user to see a welcome message when they log in? Add it to the skeleton:

bash
# Add a README to the skeleton
sudo vim /etc/skel/README.txt
README.txt
Welcome to dept-server01.
Please change your password on first login.
For access issues, contact the sysadmin team.
Company IT policy: https://internal.company.com/it-policy

Want every new user to have a standard directory structure?

bash
sudo mkdir -p /etc/skel/{projects,scripts,logs}

# Now when you create a new user:
sudo useradd -m -s /bin/bash testuser
ls /home/testuser/
# projects  scripts  logs  README.txt

The skeleton directory is underrated. In a real environment, this saves you from manually setting up every new user’s home directory.

Groups. Primary vs supplementary.

Every user has exactly one primary group and zero or more supplementary groups. This distinction matters more than most people realize.

Primary group: Determines the default group ownership of files the user creates. When jmorales creates a file, it belongs to jmorales:jmorales (user:primary_group).

Supplementary groups: Determine what shared resources the user can access. Being in the developers group lets you read and write to /opt/projects. Being in the wheel group gives you sudo access.

bash
# Create groups
sudo groupadd developers
sudo groupadd dbadmin
sudo groupadd monitoring

# Add a user to supplementary groups at creation
sudo useradd -m -s /bin/bash -G developers,monitoring kpatel

# Add an existing user to more groups (APPEND, don't replace)
sudo usermod -aG dbadmin kpatel

# Check the result
id kpatel
# uid=1002(kpatel) gid=1002(kpatel) groups=1002(kpatel),1001(developers),1003(dbadmin),1004(monitoring)
warning: The -a flag in usermod -aG means APPEND. If you forget the -a and just run usermod -G dbadmin kpatel, you will REPLACE all supplementary groups with just dbadmin. The user will be removed from developers and monitoring. This is the single most common usermod mistake. Always use -aG together.

Remove a user from a group:

bash
sudo gpasswd -d kpatel dbadmin
# Removing user kpatel from group dbadmin

Locking and disabling accounts

When someone leaves the company, your first instinct might be to delete their account. Don’t. At least not right away. There might be files, running processes, or audit requirements tied to that account. Lock it first, investigate, then delete if appropriate.

bash
# Lock the account (adds ! to the password hash, preventing login)
sudo usermod -L jmorales

# Verify the lock
sudo grep jmorales /etc/shadow
# jmorales:!$6$hash...:19837:...
# The ! prefix means locked

# Also expire the account immediately
sudo chage -E 0 jmorales

# Verify
sudo chage -l jmorales
# Account expires: Jan 01, 1970 (effectively immediately)

# Change shell to nologin as a belt-and-suspenders measure
sudo usermod -s /sbin/nologin jmorales

Now the account is triple-locked: password is locked, account is expired, and the shell prevents interactive login. Even if someone somehow bypasses one layer, the other two stop them.

To unlock later:

bash
sudo usermod -U jmorales          # unlock password
sudo chage -E -1 jmorales         # remove expiration (-1 means never)
sudo usermod -s /bin/bash jmorales # restore shell

Service accounts. Users that should never log in.

Not every user account is for a human. Applications, daemons, and services often need their own user to run under. These accounts should never have a password and should never be able to log in interactively.

bash
# Create a service account
sudo useradd \
  -r \                     # system account (UID below 1000)
  -s /sbin/nologin \       # no interactive login
  -d /opt/myapp \          # home directory is the app directory
  -M \                     # do NOT create home directory (it already exists)
  -c "MyApp Service" \     # description
  myapp

The -r flag creates a system account with a UID below 1000. This is a convention that separates human users (UID 1000+) from service/system accounts (UID below 1000). Tools like getent and login screens often filter based on this.

The -s /sbin/nologin flag means if anyone tries to SSH in as this user or su to it, they get a message saying “This account is currently not available” and the session closes. The service can still run as this user, it just can’t be used for interactive access.

Deleting users properly

When it’s finally time to delete an account, don’t just userdel username. Think about what files that user owned.

bash
# Find all files owned by the user BEFORE deleting
sudo find / -user jmorales -type f 2>/dev/null > /tmp/jmorales-files.txt
wc -l /tmp/jmorales-files.txt

# Check if any processes are running as this user
ps -u jmorales

# Delete the user AND their home directory
sudo userdel -r jmorales

# Without -r, the home directory stays behind as an orphan
# With -r, home directory and mail spool are removed

After deleting a user, any files they owned outside their home directory become orphaned. They’ll show a UID number instead of a username when you ls -l them. Clean these up:

bash
# Find orphaned files (owned by a UID that no longer exists)
sudo find / -nouser -type f 2>/dev/null

# Reassign them to another user or delete them
sudo find /opt/projects -nouser -exec chown rchen:developers {} \;

Sudo configuration deep dive

The wheel group gives blanket sudo access. That’s fine for sysadmins. But for everyone else you want targeted permissions. Let’s go deeper than the basics.

bash
sudo visudo -f /etc/sudoers.d/custom-roles
/etc/sudoers.d/custom-roles
# Developers: restart web server only
%developers ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart httpd, /usr/bin/systemctl status httpd

# DB admins: manage MySQL only
%dbadmin ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart mysqld, /usr/bin/systemctl status mysqld, /usr/bin/mysqldump

# Monitoring team: read logs only
%monitoring ALL=(ALL) NOPASSWD: /usr/bin/journalctl, /usr/bin/tail /var/log/*

# Specific user: can run one specific script
kpatel ALL=(ALL) NOPASSWD: /opt/scripts/deploy.sh

Each line follows the pattern: WHO WHERE=(AS_WHOM) WHAT. The % prefix means it’s a group. ALL=(ALL) means from any host, as any user. NOPASSWD: skips the password prompt. The command paths must be absolute.

Verify what a specific user can do:

bash
sudo -l -U kpatel

Practical exercises

Exercise 1: Create a complete user with all options

bash
# Create user with specific UID, groups, shell, expiration, and full name
sudo useradd -m -s /bin/bash -G developers,monitoring -c "Test User" -e 2026-12-31 -u 2050 testuser1

# Set password and force change on first login
echo "testuser1:Welcome123!" | sudo chpasswd
sudo chage -d 0 testuser1

# Verify everything
id testuser1
sudo chage -l testuser1
ls -la /home/testuser1/
grep testuser1 /etc/passwd

Exercise 2: Set up password policies

bash
# Set max age 60 days, min age 2 days, warning 7 days
sudo chage -M 60 -m 2 -W 7 testuser1

# Verify
sudo chage -l testuser1

# Lock the account
sudo usermod -L testuser1

# Verify the lock in /etc/shadow (look for ! prefix)
sudo grep testuser1 /etc/shadow

# Unlock it
sudo usermod -U testuser1

Exercise 3: Create a service account

bash
# Create a system account for an application
sudo useradd -r -s /sbin/nologin -c "Monitoring Agent" monitoragent

# Verify the UID is below 1000
id monitoragent

# Try to log in as this user (should fail)
su - monitoragent
# This account is currently not available.

Exercise 4: Group management and the usermod trap

bash
# Add testuser1 to three groups
sudo usermod -aG developers,dbadmin,monitoring testuser1
id testuser1
# Should show all three supplementary groups

# Now do it WRONG on purpose (omit the -a flag)
sudo usermod -G developers testuser1
id testuser1
# dbadmin and monitoring are GONE

# Fix it by adding them back
sudo usermod -aG dbadmin,monitoring testuser1
id testuser1
# All three are back

Exercise 5: Full lifecycle. Create, configure, lock, delete.

bash
# 1. Create the user
sudo useradd -m -s /bin/bash -G developers -c "Temp Contractor" -e 2026-06-30 contractor1
echo "contractor1:Temp999!" | sudo chpasswd
sudo chage -d 0 contractor1

# 2. Verify everything
id contractor1
sudo chage -l contractor1

# 3. The contractor's project ends early. Lock the account.
sudo usermod -L contractor1
sudo chage -E 0 contractor1
sudo usermod -s /sbin/nologin contractor1

# 4. Find all files they own
sudo find / -user contractor1 -type f 2>/dev/null

# 5. Reassign their project files
sudo find /opt/projects -user contractor1 -exec chown rchen:developers {} \;

# 6. Delete the account and home directory
sudo userdel -r contractor1

# 7. Verify they're gone
grep contractor1 /etc/passwd
# No output, they're gone

# 8. Check for orphaned files
sudo find / -nouser -type f 2>/dev/null

Final checklist: monthly access review

Run this monthly. Seriously. Accounts accumulate access over time and nobody notices until something goes wrong.

bash
# 1. All users with login shells (should recognize every name)
getent passwd | awk -F: '$7 ~ /bash|sh/' | cut -d: -f1

# 2. All users with sudo access (should be minimal)
sudo grep -r 'ALL=(ALL)' /etc/sudoers /etc/sudoers.d/

# 3. Users who haven't logged in recently (30+ days = investigate)
sudo lastlog | grep -v "Never logged in" | grep -v Username

# 4. Locked accounts (should match people who left)
sudo awk -F: '$2 ~ /^!/' /etc/shadow | cut -d: -f1

# 5. Accounts expiring soon
sudo awk -F: '$8 != "" && $8 != -1' /etc/shadow | cut -d: -f1,8

# 6. Orphaned files (files owned by deleted users)
sudo find /opt /srv /var/www -nouser -type f 2>/dev/null

# 7. Group memberships (review who's in privileged groups)
getent group wheel
getent group developers
getent group dbadmin

If any of those checks reveals something unexpected, investigate. A dormant account with wheel access is a security incident waiting to happen. An orphaned file in a web directory could contain sensitive data. Access review is boring but it’s one of the most important things you can do as a sysadmin.

next post
Setting up autofs for automatic NFS mounts