Permission Denied Errors¶
"Permission denied" is one of the most common — and most misdiagnosed — errors on Linux. The trap is that the file's ls -l permissions can look perfect and the operation still fails, because a second, invisible access-control layer (SELinux) is denying it. This playbook walks the whole stack, from the obvious bits to the AVC in the audit log.
Tested on
AlmaLinux 9 / RHEL 9 (and Rocky/CentOS Stream 9) with the default targeted SELinux policy. Standard-permission, ACL, and mount commands are identical on Debian/Ubuntu; the SELinux sections do not apply there (Debian/Ubuntu use AppArmor instead).
Symptom¶
You hit one of these:
- Opening or writing a file fails:
cat: report.txt: Permission deniedorbash: file.txt: Permission denied. - Running a script fails even though it exists:
./deploy.sh: Permission denied. - A service cannot read or write its own data — for example a web server returns 403, or a daemon logs
Permission deniedand refuses to start. cdinto a directory fails:bash: cd: /srv/app: Permission denied.
The error text is the same regardless of which layer denied you, so the whole job is figuring out which layer.
Likely causes¶
| Cause | Tell-tale sign |
|---|---|
Wrong file permissions (rwx bits) |
ls -l shows you lack the needed bit |
| Wrong ownership | File owned by another user/group; you are neither |
| Missing execute bit on a script/binary | ls -l shows no x; bash script.sh works but ./script.sh does not |
| Missing execute (search) bit on a parent directory | A directory in the path lacks x — you cannot traverse it |
| Restrictive umask | Newly created files come out without the bits you expected |
| ACLs overriding the visible mode | ls -l shows a trailing +; the standard bits lie |
Mount options (noexec, ro) |
The filesystem itself forbids execution or writing |
| SELinux context mismatch | ls -l perms are correct, but access is still denied — the denial appears as an AVC in the audit log, not in normal perms |
The key distinction: permissions vs. labels
Standard permissions (Discretionary Access Control) are what ls -l shows — owner, group, and rwx bits. SELinux labels (Mandatory Access Control) are a separate layer shown by ls -Z. A request must pass both. So a file can have a flawless -rw-r--r-- root root mode and still be denied because its SELinux type is wrong. When DAC looks fine but you are still blocked, suspect SELinux.
Diagnose¶
Work from the most common cause to the least, and don't stop at the first thing that looks plausible.
1. Standard permissions and ownership¶
Read the mode (-rwxr-x---), the owner, and the group, then compare against your id output. If you are not the owner and not in the group, you only get the "other" bits.
2. Walk the entire path¶
To open /srv/app/data/x.db you need the execute (search) bit on every directory along the way — /, /srv, /srv/app, /srv/app/data. A single directory missing x for you breaks the whole chain, even if the file itself is world-readable. namei -l makes this obvious:
f: /srv/app/data/x.db
drwxr-xr-x root root /
drwxr-xr-x root root srv
drwxr-x--- root deploy app <-- you are not 'deploy' -> no traverse
drwxr-xr-x deploy deploy data
-rw-r--r-- deploy deploy x.db
3. Execute bit on scripts¶
No x for you? That is why ./deploy.sh fails while bash deploy.sh works (the latter does not need the file to be executable).
4. ACLs¶
A + at the end of the mode (-rw-r--r--+) means ACLs are in play and the displayed bits are not the whole story:
5. Privilege escalation (sudo)¶
If the action needs root, confirm what you are actually allowed to run:
6. Mount options¶
A filesystem mounted noexec blocks execution regardless of the x bit; ro blocks all writes:
Look for noexec, ro, or nosuid in the options.
7. SELinux — the non-obvious one¶
If everything above checks out and you are still denied, look at SELinux. Crucially, an SELinux denial does not appear in ls -l — it is logged as an AVC (Access Vector Cache) denial in the audit log:
getenforce # Enforcing means SELinux can deny
ls -Z /path/to/file # show the SELinux label/type
sudo ausearch -m avc -ts recent # recent SELinux denials
sudo sealert -a /var/log/audit/audit.log # human-readable analysis + suggested fix
A typical AVC line names the source type (e.g. httpd_t), the target file, and the target type (e.g. default_t instead of the expected httpd_sys_content_t). That mismatch is the problem. See SELinux for the full model.
Fix¶
Apply the fix that matches what you found — do not blindly chmod 777.
Standard permissions and ownership¶
sudo chown deploy:deploy /srv/app/data/x.db # fix ownership
sudo chmod u+rw /srv/app/data/x.db # grant the bit you need
Execute bit¶
Directory traversal bits¶
Give traverse (x) on the directories in the path — without making their contents readable if you don't want a directory listing:
ACLs¶
sudo setfacl -m u:deploy:rX /srv/app/data # grant deploy read+traverse
sudo setfacl -R -m u:deploy:rwX /srv/app/data # recursive; X = dir-x only
See User & Permission Management for ownership, groups, and ACL patterns in depth.
Mount options¶
Remount without the blocking option (and fix /etc/fstab so it survives reboot):
SELinux¶
If the label is wrong, the correct fix is almost never to disable SELinux. Relabel to the policy default:
If the path lives somewhere non-standard, teach the policy the correct type first, then relabel:
chcon sets a label too, but it is temporary — a relabel or restorecon will revert it, so use it only for quick testing:
When the denial is about a capability the policy gates behind a boolean (e.g. letting Apache make network connections), flip the boolean persistently:
Don't reach for chmod 777 or setenforce 0
chmod 777 makes files world-writable — a real security hole — and rarely fixes the actual cause. setenforce 0 masks SELinux problems instead of solving them and leaves you exposed. Diagnose the layer, then fix that layer.
Prevent¶
- Least privilege. Grant the narrowest mode that works. Use group ownership and ACLs to share access instead of widening "other" bits.
- Set a sane umask.
022(files644, dirs755) is the default; tighten to027for service accounts so new files aren't world-readable. - Understand contexts. When deploying to a non-standard path, set the
semanage fcontextrule up front sorestoreconalways knows the right label — and so a future relabel doesn't break you. - Read the audit log first. When DAC looks correct,
ausearch -m avc/sealertwill usually hand you the exact fix. See Logs & journald for log navigation. - Test traversal end to end. Use
namei -lafter deploys to confirm the service account can reach its data through every parent directory.