open atlas
↑ Back to track
Linux, the operating system LIN · 05 · 01

passwd and shadow

Every Linux process runs as a UID. /etc/passwd maps UIDs to usernames and shells; /etc/shadow holds the actual password hash and aging policy. Understanding the split — and the hash format — is how you reason about identity and credential storage.

LIN Junior ◷ 20 min
Level
FoundationsJuniorMiddleSenior

You add a new service account and run sudo -u myservice /opt/app/run. It fails with “No such user”. The user exists in your provisioning script — but getent passwd myservice returns nothing. Or you inherit a server and find root:x:0:0:root:/root:/bin/bash in /etc/passwd and a shadow file you cannot read. Or a security audit asks “how are passwords stored on this host?” and you need a real answer, not “hashed somehow”. Identity on Linux runs through two flat text files that have been there since 1979. Knowing their format cold is the floor for reasoning about anything that involves users, permissions, or authentication.

Goal

After this lesson you can read every field in an /etc/passwd entry, explain why /etc/shadow exists as a separate file, parse the $id$salt$hash format, distinguish system users from human users, and create and modify users with useradd/usermod.

1

Every process on Linux runs as a numeric UID. The kernel tracks identity as a 32-bit unsigned integer — the username you see in ls -l or ps is a display convenience provided by libc’s getpwuid() lookup. The kernel cares only about the number.

# Your own UID and GID
id
# uid=1000(alice) gid=1000(alice) groups=1000(alice),27(sudo),1001(docker)

# The numeric UID of a running process
ps -o pid,uid,user,comm -p $$
# PID   UID USER     COMMAND
# 4231  1000 alice    bash

# The kernel API — syscall returns the numeric UID directly
grep -m1 '^Uid' /proc/$$/status
# Uid:    1000    1000    1000    1000
# (real  effective  saved  filesystem UIDs)

The four UID values matter when privilege escalation is involved — the effective UID is what determines what you are allowed to do right now, while the real UID records who you actually are. We will revisit this distinction when we discuss setuid binaries in the next lesson.

2

/etc/passwd has seven colon-delimited fields. Every user — human or system — lives in this file. It is world-readable because programs need to resolve UIDs to names.

# View a specific entry
getent passwd alice
# alice:x:1000:1000:Alice Smith,,,:/home/alice:/bin/bash
# [1]  [2][3] [4]  [5]          [6]          [7]

# Field breakdown:
# 1 — login name (alice)
# 2 — password field: 'x' means "look in /etc/shadow"; historically the actual hash was here
# 3 — UID (1000)
# 4 — primary GID (1000) — the group the process starts with
# 5 — GECOS: free-text comment, often Full Name,Office,Phone
# 6 — home directory (/home/alice)
# 7 — login shell (/bin/bash) — 'nologin' or 'false' blocks interactive login

The password field being x rather than the actual hash is the shadow split in action. Before shadow passwords (1980s Unixes), the hash lived right here — and because the file was world-readable, anyone on the system could run an offline dictionary attack. Moving hashes to a root-only file was the fix.

3

/etc/shadow holds the actual hash and aging policy. It is readable only by root (mode 640, owned root:shadow on Debian/Ubuntu). The format is nine colon-delimited fields.

# Only root can read shadow directly
sudo grep '^alice:' /etc/shadow
# alice:$6$rounds=5000$SaltGoesHere$LongHashValue...:19800:0:99999:7:::

# Field breakdown:
# 1 — login name (matches passwd)
# 2 — password hash in $id$[param$]salt$hash format
# 3 — last change: days since 1970-01-01 (19800 = some date)
# 4 — min days before password can be changed (0 = no minimum)
# 5 — max days before password must be changed (99999 = never)
# 6 — warning days before expiry (7 = warn 7 days ahead)
# 7 — inactive grace days after expiry before account is locked
# 8 — account expiry date (days since epoch; empty = no expiry)
# 9 — reserved

# Parse the hash algorithm from the $id$ prefix:
# $1$  = MD5 (ancient, DO NOT use)
# $5$  = SHA-256
# $6$  = SHA-512 (current Debian/Ubuntu default)
# $y$  = yescrypt (Ubuntu 22.04+, memory-hard)
# $2b$ = bcrypt (some distros)

The $id$salt$hash triple is a self-describing crypt(3) string. The algorithm ID tells the system which function to use for verification — this is why you can migrate to a stronger algorithm: new password changes produce the new format, old hashes still verify with their original algorithm until the user changes their password.

4

System users and human users differ by UID range and shell. The convention (configurable in /etc/login.defs) is:

# UID ranges on Debian/Ubuntu (from /etc/login.defs)
grep -E '^(UID_MIN|UID_MAX|SYS_UID_MIN|SYS_UID_MAX)' /etc/login.defs
# SYS_UID_MIN   100
# SYS_UID_MAX   999
# UID_MIN      1000
# UID_MAX     60000

# System users (UIDs 100-999) run daemons; they have no home dir or shell
getent passwd www-data
# www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

getent passwd nobody
# nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

# Human users start at UID 1000
getent passwd alice
# alice:x:1000:1000:Alice Smith,,,:/home/alice:/bin/bash

A system account with /usr/sbin/nologin as its shell cannot be used for interactive login — even if you somehow get a password for it, the kernel will fork the shell, but nologin immediately prints “This account is currently not available” and exits. This is the correct way to create a daemon account that should never be SSH’d into.

5

useradd and usermod are how you build and modify users. The key flags encode exactly the fields above.

# Create a system user for a daemon (no home dir, no login shell)
sudo useradd \
  --system \           # UID from system range (100-999)
  --no-create-home \   # don't create /home/myservice
  --shell /usr/sbin/nologin \
  --comment "MyService daemon" \
  myservice

# Verify
getent passwd myservice
# myservice:x:123:123:MyService daemon:/:/usr/sbin/nologin

# Create a human user with home dir and bash
sudo useradd \
  --create-home \
  --shell /bin/bash \
  --comment "Bob Jones" \
  bob
sudo passwd bob          # set password interactively (writes to shadow)

# Modify an existing user: add to supplementary group
sudo usermod --append --groups docker alice
# --append is CRITICAL: without it, --groups REPLACES all supplementary groups

# Lock an account (prepend ! to shadow hash — login fails, key-based SSH still works)
sudo usermod --lock bob
sudo grep '^bob:' /etc/shadow
# bob:!$6$...:...   <- the ! prefix

The --append flag on usermod -G is a classic gotcha. Without it, sudo usermod --groups docker alice would set alice’s groups to only docker, stripping her from sudo and every other group she belongs to. Always pair --groups with --append.

Worked example

Diagnosing a service account that cannot be created by a provisioning script.

A deployment script on a new server fails with useradd: user 'apprunner' already exists. But id apprunner returns “no such user”.

# Step 1: check if the UID is already taken (not the name)
grep ':999:' /etc/passwd
# oldservice:x:999:999:Old daemon:/:/usr/sbin/nologin
# (someone ran useradd --system oldservice before; it took UID 999)

# Step 2: find the actual issue — the name is taken in a different file
getent passwd apprunner
# (no output — not in /etc/passwd)

# Hmm — check nsswitch.conf; the system might resolve from LDAP or NIS
grep passwd /etc/nsswitch.conf
# passwd:         files systemd ldap
# ldap is in the chain — there is an LDAP entry for apprunner

# Step 3: confirm from LDAP
ldapsearch -x -b "dc=corp,dc=example,dc=com" "(uid=apprunner)" uid uidNumber 2>/dev/null
# dn: uid=apprunner,ou=service,dc=corp,dc=example,dc=com
# uid: apprunner
# uidNumber: 2500

# Step 4: create locally with an explicit UID that does not conflict
sudo useradd --system --uid 450 --no-create-home --shell /usr/sbin/nologin apprunner
# Now getent passwd apprunner returns the local entry (files takes precedence over ldap)

The key insight: useradd: user already exists means the name was found somewhere in the nsswitch resolution chain — not necessarily in /etc/passwd. Always check getent passwd (which respects nsswitch) rather than grep /etc/passwd (which sees only the local file) when diagnosing user-lookup failures.

Why this works

Why did the password move out of /etc/passwd in the first place? In early Unix, /etc/passwd held the actual DES-encrypted hash, and the file had to be world-readable so that programs like ls could display usernames. Any local user could copy the file and run a dictionary attack offline at their leisure. The shadow password system (standardized around 1988) split the file: usernames, UIDs, and shells stay world-readable in passwd; the hashes move to /etc/shadow, owned root:shadow, mode 640. This is also why the shadow group exists on Debian — programs like su and ssh can read shadow by joining that group without running as full root.

Common mistake

On macOS, none of this applies. macOS uses Directory Services (dscl) backed by Open Directory rather than flat text files. There is no /etc/shadow, no useradd, and /etc/passwd on macOS is a static compatibility stub for a handful of system accounts — it is not the real user database. If you are writing provisioning scripts that need to run on both Linux and macOS, always gate the useradd/shadow logic behind uname -s or use an abstraction layer.

Check yourself
Quiz

You run 'grep apprunner /etc/passwd' and get no output, but 'sudo useradd apprunner' fails with 'user already exists'. What is the most likely explanation?

Recap

Linux identity is numeric: every process runs as a UID and GID. /etc/passwd maps those numbers to names, home directories, and shells — it is world-readable. /etc/shadow holds the actual password hash ($id$salt$hash format, where $6$ = SHA-512, $y$ = yescrypt) plus aging fields — it is root-only (mode 640). System users (UID 100–999) run daemons with /usr/sbin/nologin and no home directory. Human users start at UID 1000. useradd creates users; usermod modifies them — always use --append with --groups to avoid replacing existing group memberships. Use getent passwd rather than grep /etc/passwd when nsswitch resolves users from LDAP or other sources.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.