File Permissions, Ownership & ACLs
Mental Model
A building with three types of keys: the owner's master key, the department's group key, and a visitor pass. Each door has three locks -- one per key type. The guard checks in order. If the person is the owner, only the master lock matters. The visitor lock could be wide open and the guard would not even look at it. That is why "Permission denied" fires even when "others" has full access -- the check stopped at the owner class.
The Problem
403 Forbidden on a file with mode 644 -- world-readable, permissions look fine. The real problem: the parent directory is missing the execute bit, and without it the kernel refuses to traverse in. On another server, a developer runs chmod 777 on a deployment directory to "fix permissions." Now every user on the shared machine can read the database credentials sitting in a config file inside that directory.
Architecture
"Permission denied" on a file that's clearly readable. Running ls -la shows rw-r--r--, and the permissions look fine. But the file is inside a directory with mode 0700 that belongs to another user.
There was never a chance. The kernel rejected the request at the directory, not the file. And no amount of staring at the file's permissions will reveal that.
This is what makes Linux permissions tricky. The rules are simple — 12 bits, three classes, no fallthrough — but the consequences are subtle. And the most common "fix" (chmod 777) is the equivalent of removing the lock from the front door because the key was lost.
What Actually Happens
When a process calls open(), the kernel runs this algorithm on every path component and the target file:
- Does the process have
CAP_DAC_OVERRIDE? (Root usually does.) If yes, bypass everything. - Does the process's
fsuidmatch the file's owner UID? If yes, check ONLY the owner bits (bits 8-6). Done. - Does the process's
fsgidor any supplementary GID match the file's group? If yes, check ONLY the group bits (bits 5-3). Done. - Check the "other" bits (bits 2-0). Done.
No fallthrough. No combining. The first matching class wins.
When POSIX ACLs are present (the + in ls -l output), the algorithm gets an extension. After determining the process is not the owner, the kernel searches ACL entries for a named user matching the process's uid, then named groups matching any of its GIDs, then falls back to the owning group entry. All non-owner matches are capped by the ACL mask entry — an upper bound that limits effective permissions. Running chmod g-w on an ACL-enabled file actually modifies the mask, which silently restricts every non-owner ACL entry. This surprises people constantly.
File creation and umask. When a process calls open("file", O_CREAT, 0666) with umask 022, the effective permissions are 0666 & ~022 = 0644. But if the parent directory has a default ACL, umask is completely ignored — the new file inherits the directory's default ACL entries instead. Default ACLs let administrators enforce consistent permissions without depending on every process having the right umask.
Under the Hood
setuid/setgid execution. When the kernel execve()s a binary with the setuid bit, it sets the process's effective UID to the file owner's UID before the first instruction runs. The real UID stays unchanged — that's how the program knows who actually invoked it. /usr/bin/passwd is owned by root with setuid set. When someone runs it, the effective UID becomes 0 (so it can write to /etc/shadow), but the real UID remains the invoking user's (so it knows which password to change). After the write, the program can drop privileges back down.
The sticky bit. On directories, the sticky bit (mode 1000, the t in the other-execute position) restricts deletion. Even with write permission to /tmp, a user can only delete files they own. Without sticky, any user could delete any file in a world-writable directory. /tmp (mode 1777) relies on this — all users can create files, but only the file owner (or root) can delete them.
Permission caching. The kernel doesn't re-read permissions from disk on every check. Permission results are cached through the dentry cache. An open() traverses each path component, checking the directory's execute bit at each step. The inode's permission callback can implement custom logic — NFS inodes revalidate against the server, while local filesystems use the standard VFS check against i_mode.
The execute bit on directories. This is the single most confusing aspect of Linux permissions. On a file, x means "can execute as a program." On a directory, x means "can traverse through." The x bit is needed on every directory in a path to reach a file — even if the file itself is world-readable. The read bit (r) on a directory controls listing (readdir). A directory can have x without r (traverse but can't list) or r without x (see names but can't access anything). Nginx's "Permission denied" errors are almost always a missing x bit on some parent directory.
Common Questions
How can a user with no read permission on a file still delete it?
Deleting a file is a write operation on the directory, not the file. unlink() removes a directory entry, so write + execute on the directory is required. The file's own permissions are irrelevant for deletion. This is why the sticky bit exists — on directories like /tmp, it adds the extra requirement that only the file owner can delete it.
Why is access() dangerous for setuid programs?
access() checks the real UID, and the check is a separate syscall from the actual open(). Between access() returning "yes" and the subsequent open(), an attacker can replace the file (symlink race). A setuid program that does if (access(file, R_OK)) open(file) can be tricked into opening a different file than what was checked. The safe pattern: just open() the file, then use fstat() on the resulting fd to verify it's what was expected.
How do supplementary groups affect permission checks?
A process has a primary GID (from /etc/passwd) plus up to NGROUPS_MAX (typically 65536) supplementary GIDs (from /etc/group). During the group phase of permission checking, the kernel checks the file's GID against the process's fsgid AND all supplementary GIDs. A user in the docker group gets group-level permissions on /var/run/docker.sock even if docker isn't their primary group.
What happens when chmod runs on a file with ACLs?
chmod modifies the ACL mask entry, not the traditional group bits. The mask acts as an upper bound on all non-owner ACL entries. So chmod g-w doesn't just remove group write — it caps every named user and named group ACL entry to exclude write. This is technically correct per the POSIX ACL spec, but it regularly surprises administrators who don't realize ACLs are present.
How Technologies Use This
A host directory is bind-mounted into a container and the app gets "Permission denied" even though the file shows rw-r--r-- on the host. The permissions look correct. The file clearly exists. This affects roughly 30% of first-time Docker volume mount issues.
The container process runs as UID 1000, but the host files are owned by UID 501. Linux permission checks match against numeric UIDs, not usernames, and container user namespaces map UIDs differently. The name "appuser" inside the container means nothing if its numeric UID does not match the file owner on the host filesystem.
For image files, use COPY --chown app:app in the Dockerfile. For bind mounts, either align the container UID to the host file owner, or enable userns-remap to shift the entire UID range. The kernel checks numbers, not names.
A developer carefully sets a script to 0750, commits it, and a teammate clones the repo. The file arrives as 0755. A config file set to 0640 shows up as 0644. The carefully chosen permissions vanished during the clone.
Git only tracks one permission bit: executable or not (0755 vs 0644). All other permission bits are discarded. On clone, files are created with the local umask, not the original author's permissions. A file that was 0640 on the source becomes 0644 or 0600 depending on the cloner's umask.
For shared repositories where multiple developers push, git init --shared=group sets the setgid bit on .git/objects so all pack files inherit group ownership. This prevents "unable to create object" errors that affect roughly 20% of teams setting up shared bare repos for the first time. For fine-grained permissions, enforce them in a post-checkout hook, not in Git's index.
Nginx returns 403 Forbidden on a file that is clearly world-readable. Checking ls -la on the file shows rw-r--r--, and the permissions look perfect. An hour goes by checking Nginx config, SELinux, and file ownership before realizing the problem is not the file at all.
Nginx binds port 80 as root, then drops privileges to the www-data user (typically UID 33). A developer serves files from /home/user/public_html, but /home/user is mode 0700. The kernel rejects the path walk at /home/user because www-data lacks the execute bit on that directory. The file permissions are irrelevant if the kernel cannot traverse a parent directory.
Run namei -l /home/user/public_html/index.html to reveal exactly which path component blocks traversal. Every directory in the path to a served file must have the execute bit for the Nginx worker user. This is a directory permission problem, not a file permission problem.
Same Concept Across Tech
| Technology | How permissions affect it | Key gotcha |
|---|---|---|
| Docker | Container processes run as root by default. Files created inside may have wrong ownership on bind mounts | Use USER directive in Dockerfile, match UID/GID with host |
| Kubernetes | Pod securityContext sets runAsUser, runAsGroup, fsGroup | fsGroup changes file ownership on volume mount |
| Nginx | Worker processes run as nginx user. Served files must be readable by that user | 403 often caused by wrong file ownership, not permissions |
| Git | Tracks execute bit only, not full permissions. Mode changes show in diff | umask on the server determines cloned file permissions |
| SSH | Strict permissions on ~/.ssh (700) and keys (600). Rejects if too open | Most common SSH login failure after permissions change |
Stack layer mapping (Permission denied debugging):
| Layer | What to check | Tool |
|---|---|---|
| Application | Which user/group is the process running as? | ps -eo user,group,pid,comm |
| File | What are the file's permissions and ownership? | ls -la, stat |
| Directory | Does every parent directory have execute (x) for the running user? | namei -l /full/path |
| ACL | Are there extended ACLs overriding basic permissions? | getfacl |
| SELinux/AppArmor | Is a MAC policy blocking access despite DAC permissions? | getenforce, ausearch -m AVC |
Design Rationale Twelve bits was the minimal representation that could express private files, team-shared files, and world-readable files while fitting in a 16-bit mode field alongside the file type. Full access control lists for every file would have required variable-length inode metadata and more complex on-disk formats -- impractical on 1970s hardware. The no-fallthrough rule is deliberate: ORing all matching classes would make it impossible for an owner to have fewer permissions than the group, which is sometimes useful for safety. POSIX ACLs arrived decades later as an extension, not a replacement, because changing the base model would have broken every existing Unix program.
If You See This, Think This
| Symptom | Likely cause | First check |
|---|---|---|
| Permission denied on world-readable file | Parent directory missing execute bit | namei -l /path/to/file |
| Owner gets denied but others can access | Owner bits are more restrictive than others bits | ls -la, check first octal digit |
| Files created with wrong permissions | umask too restrictive or too permissive | Check umask in shell and in application |
| Docker container cannot read bind-mounted files | UID inside container does not match file owner on host | Match UIDs or use --user flag |
| SSH refuses key despite correct key | ~/.ssh permissions too open (must be 700, keys must be 600) | ls -la ~/.ssh/ |
| setuid bit ignored on script | Linux ignores setuid on interpreted scripts (security measure) | Compile as binary or use sudo |
When to Use / Avoid
Relevant when:
- Debugging 403 errors or "Permission denied" on files that look accessible
- Securing sensitive files (SSH keys, database credentials, TLS certificates)
- Understanding setuid binaries (sudo, passwd, ping)
- Setting up shared directories for teams (sticky bit, setgid)
Watch out for:
- Missing execute bit on parent directories (blocks traversal, not just listing)
- Owner permissions checked FIRST, not ORed with group/others. Owner bits can be MORE restrictive.
- setuid on scripts is ignored on Linux (only works on compiled binaries)
- umask affects newly created files (default umask 022 means files get 644, not 666)
Try It Yourself
1 # Show detailed permissions with ACLs
2
3 ls -la /etc/passwd && getfacl /etc/passwd 2>/dev/null
4
5 # Find all setuid binaries on the system
6
7 find /usr -perm -4000 -type f 2>/dev/null | head -10
8
9 # Show permission check path for a file
10
11 namei -l /etc/shadow 2>/dev/null || echo 'namei not available'
12
13 # Set up ACL: grant specific user access
14
15 touch /tmp/acl_test && setfacl -m u:nobody:rw /tmp/acl_test 2>/dev/null && getfacl /tmp/acl_test 2>/dev/null && rm /tmp/acl_test
16
17 # Check current umask and demonstrate its effect
18
19 umask && bash -c 'umask 077; touch /tmp/umask_test; ls -la /tmp/umask_test; rm /tmp/umask_test'
20
21 # Show effective permissions (real UID check)
22
23 python3 -c 'import os; print("Readable:", os.access("/etc/shadow", os.R_OK)); print("UID:", os.getuid(), "EUID:", os.geteuid())' 2>/dev/null || echo 'python3 not available'Debug Checklist
- 1
Check file permissions: ls -la <file> - 2
Check all parent directory permissions: namei -l /path/to/file - 3
Check effective user/group: id - 4
Check ACLs: getfacl <file> - 5
Check umask: umask - 6
Find setuid binaries: find / -perm -4000 -type f 2>/dev/null
Key Takeaways
- ✓The permission check does NOT fall through. If your UID matches the file owner, ONLY owner bits are checked. Even if group or other bits grant full access, they're ignored. An owner with mode 0007 has zero access despite 'other' having rwx.
- ✓setuid on a binary means the process runs as the file's owner, not as you. That's how /usr/bin/passwd (owned by root, setuid) can write to /etc/shadow. The kernel checks the setuid bit during execve(), flipping the effective UID before the program's first instruction runs.
- ✓The sticky bit on /tmp (mode 1777) is the only thing stopping users from deleting each other's files. Without it, anyone with write permission on a directory can delete any file in it — regardless of the file's own permissions.
- ✓ACL mask is the sneaky upper bound — if a named user ACL grants rwx but the mask is rw-, effective permission is rw-. When you chmod on an ACL-enabled file, you're actually modifying the mask, which silently restricts every non-owner ACL entry.
- ✓access() checks the REAL UID, not the effective UID. That's deliberate — setuid programs use it to ask 'would the actual human who ran me be allowed to do this?' before performing the action with elevated privileges.
Common Pitfalls
- ✗Setting a directory to 0644 and wondering why nobody can cd into it — directories need the execute bit (x) for traversal. Without it, even the owner can't access files by name. Read without execute lets you list filenames but not stat or open anything inside.
- ✗Reaching for chmod 777 as a fix — this removes all security, sets special bits unpredictably, and is never correct for production. Diagnose the actual mismatch with 'ls -la' and 'id', then fix the specific permission that's wrong.
- ✗Setting umask carefully but seeing unexpected permissions — if the parent directory has a default ACL, umask is completely ignored for new files. The default ACL wins. This catches admins who rely on umask but have ACLs they forgot about.
- ✗Trying to chmod a symlink — Linux doesn't implement lchmod(). The 0777 shown by ls for symlinks is cosmetic. The kernel follows the symlink and checks permissions on the target, always.
Reference
In One Line
"Permission denied" on a readable file almost always means a parent directory is missing the execute bit -- run namei -l on the full path before touching chmod.