Users, groups and account lifecycle

UID 0, service accounts and off-boarding.

Intermediate14 min · lesson 3 of 24

Accounts are how every other control knows who is acting. In this lesson you audit which accounts are root-equivalent, which can log in and which can become root, create service accounts that cannot log in, off-board a person so that none of their credentials work any more, and set password ageing to match current NIST guidance instead of habit. The account files and useradd are covered in Linux essentials; password quality and lockout rules are the next lesson.

Who is root, and who can log in

Start on an untouched install. The kernel grants root's powers to user ID 0, not to the name "root", so any second account with UID 0 is a full root account that survives a change of root's password. An empty password field in /etc/shadow is the other classic finding: wherever the PAM stack allows empty passwords, that account logs in with none.

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ awk -F: '$3 == 0' /etc/passwd
root:x:0:0:root:/root:/bin/bash
$ sudo awk -F: '$2 == "" {print $1}' /etc/shadow | wc -l
0
$ sudo passwd -S -a
root L 2026-09-18 0 99999 7 -1 … sync L 2026-09-18 0 99999 7 -1 … systemd-network L 2026-09-18 -1 -1 -1 -1 … lima L 2026-09-26 0 99999 7 -1 deploy L 2026-09-26 0 99999 7 -1

Only root has UID 0 and no account has an empty password field. The CIS RHEL 10 Level 1 server profile (in scap-security-guide 0.1.82) checks both, and both checks apply unchanged to Ubuntu. passwd -S -a shows each account's password state: L is locked (no usable password), NP would be an empty password, P a usable one. Every account here is L, including root, because Ubuntu locks root's password at installation and the two people-shaped accounts log in with SSH keys; on your own server the account the installer created has a password and shows P. The columns after the date are the ageing values; -1 means unset.

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ awk -F: '$7 !~ /(nologin|false)$/ {print $1, $3, $7}' /etc/passwd
root 0 /bin/bash sync 4 /bin/sync lima 502 /bin/bash deploy 1001 /bin/bash

Only four accounts have a real shell. sync is historical: its "shell" flushes the disks, and its password is locked. lima belongs to the lab's VM tool and deploy is the administrator. Every other system account uses nologin, which is what a service account should look like.

Who can become root

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ getent group sudo admin
sudo:x:27:deploy admin:x:107:
$ sudo grep -E '^%' /etc/sudoers sudo grep -r '^[^#]' /etc/sudoers.d/
%admin ALL=(ALL) ALL %sudo ALL=(ALL:ALL) ALL /etc/sudoers.d/90-secopslog-lab:deploy ALL=(ALL) NOPASSWD: ALL /etc/sudoers.d/90-cloud-init-users:lima ALL=(ALL) NOPASSWD:ALL

Ubuntu grants full sudo to two groups, sudo and admin. admin is empty, but anyone added to it becomes an administrator, so review both. Group membership is not the whole picture: files in /etc/sudoers.d can grant rights to a named user. Here cloud-init gave the image's default user (lima on this VM, ubuntu on a stock Ubuntu cloud image) NOPASSWD: ALL, and the lab gave deploy the same so labs run unattended. On a real server both lines are findings: whoever holds that account's SSH key is root without typing a password. Narrowing sudo is the subject of the sudo lesson; the review belongs here, and it should run after every off-boarding.

Removing a cloud image's NOPASSWD line safely
On a stock cloud image the default user (ubuntu) has a locked password and root is locked too, so deleting its NOPASSWD line leaves nobody able to use sudo. First give the user a password (sudo passwd ubuntu) and keep a second session open with sudo -i. Edit with sudo visudo -f /etc/sudoers.d/90-cloud-init-users, then in a new session run sudo -k; sudo true: it must ask for the password and succeed before you close the root shell.

On RHEL 10 the administrators' group is wheel, and new accounts get bash and a 0700 home directory where Ubuntu gives /bin/sh and 0750.

deploy@rocky10 · Rocky Linux 10.2
$ getent group wheel sudo grep -E "^%wheel" /etc/sudoers
wheel:x:10:deploy %wheel ALL=(ALL) ALL
$ grep -E '^(PASS_MAX_DAYS|HOME_MODE|UMASK)' /etc/login.defs sudo useradd -D | grep SHELL
UMASK 022 HOME_MODE 0700 PASS_MAX_DAYS 99999 SHELL=/bin/bash

Service accounts

A daemon should run as its own unprivileged account, not as root and not as a person. --system picks a UID below 1000 and sets no password ageing; nologin as the shell refuses interactive logins.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd --system --shell /usr/sbin/nologin --home-dir /nonexistent hard-acct-svc getent passwd hard-acct-svc sudo passwd -S hard-acct-svc
hard-acct-svc:x:999:987::/nonexistent:/usr/sbin/nologin hard-acct-svc L 2026-09-27 -1 -1 -1 -1
$ sudo su - hard-acct-svc
su: warning: cannot change directory to /nonexistent: No such file or directory This account is currently not available.
$ sudo -u hard-acct-svc id
uid=999(hard-acct-svc) gid=987(hard-acct-svc) groups=987(hard-acct-svc)

The account has a locked password and no ageing (-1). su - starts the account's login shell, and nologin prints its refusal. sudo -u still works, because it runs the command directly without the account's shell. That is intended: nologin stops people from getting a shell, and systemd's User= and sudo -u still run programs as the account. For a daemon you write yourself, systemd can also create a temporary account per run with DynamicUser=, covered in the sandboxing lesson.

Off-boarding someone who uses SSH keys

The threat is a former employee or contractor whose access still works. The lab's leaver, hard-acct-leaver, is an administrator in the sudo group who logs in with an SSH key. To build the same starting point, create the account and give it a key whose private half deploy keeps to test with; the directory and file must belong to the account, with modes 700 and 600:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd -m -s /bin/bash -G sudo hard-acct-leaver ssh-keygen -q -t ed25519 -N '' -C 'hard-acct-leaver laptop' -f ~/.ssh/leaver_test sudo install -d -m 700 -o hard-acct-leaver -g hard-acct-leaver /home/hard-acct-leaver/.ssh sudo install -m 600 -o hard-acct-leaver -g hard-acct-leaver ~/.ssh/leaver_test.pub /home/hard-acct-leaver/.ssh/authorized_keys

The leaver also left a session running: a second connection, ssh -f -i ~/.ssh/leaver_test hard-acct-leaver@localhost 'sleep 600'. Linux essentials showed that sudo passwd -l does not stop a key login; here it is once more as the starting point:

deploy@web01 · Ubuntu 26.04 LTS
$ ssh -i ~/.ssh/leaver_test hard-acct-leaver@localhost id
uid=1002(hard-acct-leaver) gid=1002(hard-acct-leaver) groups=1002(hard-acct-leaver),27(sudo)
$ sudo passwd -l hard-acct-leaver sudo passwd -S hard-acct-leaver
passwd: password changed. hard-acct-leaver L 2026-09-27 0 99999 7 -1
$ ssh -i ~/.ssh/leaver_test hard-acct-leaver@localhost id
uid=1002(hard-acct-leaver) gid=1002(hard-acct-leaver) groups=1002(hard-acct-leaver),27(sudo)

The lock only puts a ! in front of the hash, which a key login never reads; passwd(1) says locking "does not disable the account" and points to usermod --expiredate 1. The order that closes every path:

Off-boarding, in order
1Expire the account
usermod --expiredate 1: no new logins of any kind
2End running sessions
loginctl terminate-user
3Remove keys and groups
every authorized_keys file, sudo and other groups
4Find what runs as them
crontab, timers, sudoers.d, owned files
5Archive, then delete
userdel -r after the retention period

Expire the account first, then try the key again:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo usermod --expiredate 1 hard-acct-leaver sudo chage -l hard-acct-leaver | grep "Account expires"
Account expires : Jan 02, 1970
$ ssh -i ~/.ssh/leaver_test hard-acct-leaver@localhost id
Your account has expired; please contact your system administrator. …
$ sudo journalctl -u ssh.service --since "-1 min" --no-hostname | grep hard-acct-leaver | tail -n 2
Sep 27 09:48:07 sshd-session[266874]: pam_unix(sshd:account): account hard-acct-leaver has expired (account expired) Sep 27 09:48:07 sshd-session[266874]: fatal: Access denied for user hard-acct-leaver by PAM account configuration [preauth]

Account expiry is checked for every login method: sshd refuses the key login in PAM's account stage and says why, and the journal records it under sshd-session, the per-connection process OpenSSH has used since version 9.8. Expiry does not touch sessions that are already open.

deploy@web01 · Ubuntu 26.04 LTS
$ ps -u hard-acct-leaver -o pid,cmd
PID CMD 266479 /usr/lib/systemd/systemd --user 266481 (sd-pam) 266547 sshd-session: hard-acct-leaver@notty 266548 sleep 600
$ sudo loginctl terminate-user hard-acct-leaver
$ ps -u hard-acct-leaver -o pid,cmd
PID CMD

The leaver's sleep was still running over the old connection until loginctl terminate-user ended every session and process of the account. Now remove the credentials, so that re-enabling the account by mistake does not restore access, and the rights that came with group membership.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo sshd -T | grep authorizedkeysfile sudo ls -A /home/hard-acct-leaver/.ssh
authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2 authorized_keys
$ sudo mv /home/hard-acct-leaver/.ssh/authorized_keys /root/hard-acct-leaver.authorized_keys.removed sudo gpasswd -d hard-acct-leaver sudo
Removing user hard-acct-leaver from group sudo
$ sudo crontab -l -u hard-acct-leaver
no crontab for hard-acct-leaver
$ sudo userdel -r hard-acct-leaver
userdel: hard-acct-leaver mail spool (/var/mail/hard-acct-leaver) not found

sshd -T prints the effective server configuration: keys are read from both authorized_keys and authorized_keys2, so check both. Moving the file to root's home keeps it as evidence. crontab -l -u finds scheduled jobs; also look for systemd timers and user services, sudoers.d files naming the account, and files it owns (find / -xdev -user NAME). userdel -r deletes the home directory and mail spool, so archive the home first if policy says so; the warning only means there was no mail. Keys the person created for automation elsewhere, and shared secrets they knew, must be rotated separately.

Every one of those checks looks for the leaver's name. Someone who held root and wanted to keep access would not leave anything under it: they would add a key to root's or a service account's authorized_keys, a NOPASSWD line for another account, a second UID 0 or sudo member, a root-owned cron job or unit, or a CA in sshd's TrustedUserCAKeys. For a leaver who had root, compare the host with a known-good source (your configuration management, and the file-integrity baseline from the AIDE lesson): every authorized_keys file (sshd -T shows where keys are read from, including an AuthorizedKeysCommand), /etc/sudoers.d, UID 0 and the sudo and admin groups, /etc/cron.d and the units under /etc/systemd/system, and sshd's trust settings. Rotate the host secrets they could read. On a fleet, rebuilding a host is often cheaper than proving it clean.

On a fleet, a local account must be expired on every host, and the host you forget still accepts the key, so fleets use central identity (SSSD with IdM or LDAP) or push the expiry through configuration management, and pair it with the revocation list from the SSH keys lesson. userdel also frees the UID: useradd(8) gives a new account the smallest UID above every existing one, so if the leaver had the highest UID, the next person created inherits it, and with it their files on shared storage and in backups. Keep a record of used UIDs and never hand one out twice.

The operational catch is shared accounts: you cannot expire a login that five people use, which is the best argument against them. The rollback for a mistaken expiry is sudo usermod --expiredate -1 NAME (and passwd -u for the password). On RHEL 10 the commands and their output are the same, since passwd there also comes from shadow (shadow-utils):

deploy@rocky10 · Rocky Linux 10.2
$ sudo passwd -l hard-acct-leaver sudo passwd -S hard-acct-leaver
passwd: password changed. hard-acct-leaver L 2026-09-27 0 99999 7 -1
$ sudo usermod --expiredate 1 hard-acct-leaver sudo chage -l hard-acct-leaver | grep "Account expires"
Account expires : Jan 02, 1970

Password ageing: current guidance

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ grep -E '^(PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_WARN_AGE)' /etc/login.defs
PASS_MAX_DAYS 99999 PASS_MIN_DAYS 0 PASS_WARN_AGE 7
$ sudo chage -l deploy
Last password change : Sep 26, 2026 Password expires : never Password inactive : never Account expires : never Minimum number of days between password change : 0 Maximum number of days between password change : 99999 Number of days of warning before password expires : 7

Both distributions ship PASS_MAX_DAYS 99999, so passwords never expire (the Rocky value is shown above). NIST SP 800-63B-4 (July 2025) supports that default: verifiers "SHALL NOT require subscribers to change passwords periodically", but "SHALL force a change if there is evidence that the authenticator has been compromised". It also sets a minimum of 15 characters for a password used on its own (8 when it is one factor of several), no composition rules, and a check against a list of known-bad passwords, which is the next lesson's job. The CIS RHEL 10 Level 1 profile in scap-security-guide 0.1.82 still sets a 365-day maximum, and its DISA STIG profile 60 days. SecOpsLog advice: keep the distribution default and use the controls that match real risk, unless an audit framework you are bound by demands the ageing value, in which case record it as an exception.

Two ageing tools remain useful. A temporary account gets an end date when it is created, and a suspected compromise forces a change at next login.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd -m -s /bin/bash --expiredate 2026-12-31 hard-acct-temp sudo chage -l hard-acct-temp
Last password change : Sep 27, 2026 Password expires : never Password inactive : never Account expires : Dec 31, 2026 Minimum number of days between password change : 0 Maximum number of days between password change : 99999 Number of days of warning before password expires : 7
$ sudo passwd -e hard-acct-temp sudo chage -l hard-acct-temp | head -n 2
passwd: password changed. Last password change : password must be changed Password expires : password must be changed

The account stops working on 31 December without anyone remembering to remove it; chage -E sets the same date on an existing account and chage -E -1 removes it. passwd -e marks the password as expired, so the next login that passes through PAM must set a new one, and that includes key logins: sshd runs PAM's account stage for them too, and pam_unix(8) checks password ageing there. Give hard-acct-temp a key the same way as the leaver (the lab calls it ~/.ssh/temp_test) and try it:

deploy@web01 · Ubuntu 26.04 LTS
$ ssh -i ~/.ssh/temp_test hard-acct-temp@localhost id
You are required to change your password immediately (administrator enforced). WARNING: Your password has expired. Password change required but no TTY available.
$ sudo journalctl -u ssh.service --since "-1 min" --no-hostname | grep hard-acct-temp | tail -n 3
Sep 27 09:48:07 sshd-session[267228]: Accepted publickey for hard-acct-temp from 127.0.0.1 port 51512 ssh2: ED25519 SHA256:p+D/rjsvqZpUgHn/StJWFhOCkT9wJcbbuu1LJ6zEZRc Sep 27 09:48:07 sshd-session[267228]: pam_unix(sshd:session): session opened for user hard-acct-temp(uid=1002) by hard-acct-temp(uid=0) Sep 27 09:48:07 sshd-session[267228]: pam_unix(sshd:session): session closed for user hard-acct-temp

The key was accepted and a session opened, but sshd then demanded a new password, and with no terminal to type it into, the login ended with exit status 1; an interactive user would be made to change it first. The journal also shows uid=1002 for hard-acct-temp, the UID the deleted leaver had, which is the reuse described above. A key-only automation account whose password reaches a maximum age (CIS's 365 days, for example) breaks the same way a year later, which is one more reason to leave PASS_MAX_DAYS alone for such accounts.

Do not rely on "Password inactive" to catch dormant accounts: shadow(5) counts it from the day the password expires, which with 99999 is never. Find dormant accounts from login records instead. On Ubuntu 26.04 that means the journal, because last and lastlog2 are not installed by default; this prints an account's most recent SSH login, and no output means none in the journal's history:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo journalctl -t sshd-session -g '^Accepted \w+ for hard-acct-leaver ' -n 1 -o short-iso --no-hostname
2026-09-27T09:48:07+00:00 sshd-session[266765]: Accepted publickey for hard-acct-leaver from 127.0.0.1 port 51506 ssh2: ED25519 SHA256:AFSt5RJkyKI+TH6KpKpvs+JTW8mGaQuFi7ZbYcwEbWs
Comments in /etc/login.defs
If you do change a value in /etc/login.defs, put any comment on a line of its own. login.defs(5) treats a line as a comment only when # is its first non-blank character, so PASS_MAX_DAYS 365 # policy is not a clean value of 365.

Try this

On your Ubuntu lab machine, create a user with a key as the leaver above and log in with it over ssh localhost. Run sudo passwd -l on the account and confirm that the key login still works. Then run sudo usermod --expiredate 1 and confirm that the next attempt prints "Your account has expired" and that journalctl -u ssh shows the pam_unix(sshd:account) line. Leave a sleep 600 running over a second connection before you expire the account, and prove with ps -u that it survives until sudo loginctl terminate-user.

Takeaway

Disable a person by expiring the account and ending their sessions, then remove keys, groups and scheduled work; a locked password alone leaves every key login open. Keep passwords non-expiring and force a change only when there is evidence of compromise.

Quick check
01An administrator with full sudo left. You expired the account, ended their sessions, removed their authorized_keys, their sudo membership and their crontab, and find -user shows nothing left. Why is the off-boarding not finished?
Incorrect — Expiry is checked by PAM at every new login; the lesson's expired login was refused without any restart.
Incorrect — Removing the sudo membership already took the rights away; deleting the account is the last step, after archiving.
Incorrect — A private key on the server does not grant a login to anyone; the authorized_keys entries do, and those are gone.
Correct — Look for keys in root's or service accounts' authorized_keys, sudoers.d lines, new UID 0 or sudo members, root cron jobs and units, and compare with a known-good baseline.
02A service account has /usr/sbin/nologin as its shell, yet systemd runs a daemon as it and sudo -u svc id works. Why does nologin not stop these?
Incorrect — nologin also refuses su - and console logins. What matters is whether the account's shell is started, not SSH.
Correct — nologin blocks interactive shells, which is its purpose. Programs can still run under the UID, which is what a service account is for.
Incorrect — Both use the normal account database; they simply never execute the shell listed in it.
Incorrect — Some services do open PAM sessions (PAMName=). The reason is that no shell is executed, not whether PAM runs.
03An auditor asks why PASS_MAX_DAYS is 99999 on your servers. Which answer matches NIST SP 800-63B-4?
Incorrect — Lockout limits online guessing but does nothing about a stolen or reused password. It is not NIST's reason.
Incorrect — NIST makes no such split. It says verifiers shall not require periodic changes for anyone.
Correct — SP 800-63B-4 prohibits periodic rotation and requires a forced change on evidence of compromise, alongside length and blocklist checks.
Incorrect — They do not: password ageing is checked in PAM's account stage, which key logins also pass, so an expired password stops a key login too, as the lesson showed. NIST's reason is a different one.

Related