Complete Diagnostic Guide for SELinux 'Permission denied' — When chmod Looks Fine but Access Is Still Blocked
The Real Cause of 'Permission denied' That Survives chmod 777
You've already run chmod 777 and even chown nginx:nginx, but nginx still returns 502, the page throws 403, or the app log shows Permission denied. ls -l looks perfectly fine. At this point the culprit is almost certain: SELinux.
The chmod/chown we all know is DAC (Discretionary Access Control). On RHEL, Rocky, and AlmaLinux 9/10, SELinux (MAC, Mandatory Access Control) is enabled in Enforcing mode by default. Even if DAC lets you through, the kernel blocks the request again if the MAC label (context) doesn't match. That's why you get denied even when permissions look correct.
This is not a SELinux theory lecture. It's a practical guide that organizes commands to unblock access right now, by case. If you're in a hurry, confirm the culprit in Step 1, then copy-paste only the block that matches your symptom in Step 2.
Step 1 — Confirm SELinux Is the Culprit in 30 Seconds
First, check whether SELinux is on with a single command.
getenforce
# Enforcing → SELinux is likely blocking you
# Permissive → logs only, does not block (suspect another cause)
# Disabled → SELinux is not the culpritIf it's Enforcing, capture the denial logs. To watch them in real time:
tail -f /var/log/audit/audit.log | grep denied
# In another terminal, retry the blocked action (e.g. refresh the page)For logs that already happened, use ausearch.
ausearch -m avc -ts recent # last 10 minutes
ausearch -m avc -ts today # all of todayThen translate why it was blocked into something a human can read.
ausearch -m avc -ts recent | audit2whyIf setroubleshoot is installed, sealert will even suggest a friendly fix.
dnf install -y setroubleshoot-server
sealert -a /var/log/audit/audit.logAnatomy of a Single avc: denied Log Line
A real log looks like this. The arrows show what to look at.
type=AVC msg=audit(1718440000.123:456): avc: denied { read } for
pid=2314 comm="nginx" ← ① who (process name)
name="index.html" dev="vda1" ino=12345
scontext=system_u:system_r:httpd_t:s0 ← ② source label (process)
tcontext=unconfined_u:object_r:user_home_t:s0 ← ③ target label (file) ← the culprit!
tclass=file permissive=0 ← ④ what was accessed (file/dir/tcp_socket…)The interpretation is simple: httpd_t (the web server) tried to read a file labeled user_home_t (home-directory label) and got blocked. Web content should be httpd_sys_content_t; the label is wrong, so this is a classic denial. The key clue is that tcontext is the wrong context.
Step 2 — Precise Fixes by Root Cause (The Core Section)
Looking at tclass and tcontext in the log, you can almost always tell which case you're in.
① Wrong file context (the most common case)
If tclass=file/dir and the label is something off like default_t, user_home_t, or var_t, it's a context problem. Check first:
ls -Z /var/www/html
# unconfined_u:object_r:default_t:s0 index.html ← wrongIf it's a standard path, restoring the policy default is enough.
restorecon -Rv /var/www/htmlIf you moved the web root to a non-standard path like /data/www, register a default context rule for that path first, then restore.
semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
restorecon -Rv /data/www② You need a policy for a non-standard port or directory
If you run nginx on a non-standard port like 8080, you'll get a tclass=tcp_socket, name_bind denial. Add a port label.
semanage port -a -t http_port_t -p tcp 8080
# Use -m instead of -a when modifying an already-registered port
semanage port -l | grep http_port_t # verify③ A boolean is turned off
This is the easiest case to miss. For example, if PHP needs to reach an external API or DB but httpd_can_network_connect is off, network connections get blocked. Check the current value, then turn it on.
getsebool -a | grep httpd
setsebool -P httpd_can_network_connect onBooleans that commonly get in the way:
| Boolean | Purpose | Command to enable |
|---|---|---|
httpd_can_network_connect | Web server outbound connections to external hosts/APIs | setsebool -P httpd_can_network_connect on |
httpd_can_network_connect_db | Web server connecting to a remote DB (MySQL/PostgreSQL) | setsebool -P httpd_can_network_connect_db on |
httpd_use_nfs | Using an NFS-mounted path as web content | setsebool -P httpd_use_nfs on |
httpd_enable_homedirs | Serving ~user/public_html | setsebool -P httpd_enable_homedirs on |
nis_enabled | Broad network access for NIS / external auth integration | setsebool -P nis_enabled on |
The
-Pflag is critical. Runningsetseboolwithout-Papplies the change in memory only, so it reverts after a reboot. About 90% of "I turned it on, but it blocked again after restart" incidents are a missing-P. Permanent changes always need-P.
④ You actually need a custom policy
If it's a legitimate action that doesn't match any of the three cases above, generate a dedicated policy module from the denial logs.
# Collect only nginx denials and generate a module
ausearch -c 'nginx' --raw | audit2allow -M nginxlocal
# Install the generated .pp
semodule -i nginxlocal.pp
# Read what would be allowed first, then decide
ausearch -c 'nginx' --raw | audit2allowAfter installing, retry the action and verify that no new avc: denied entries appear. Check the module list with semodule -l | grep nginxlocal.
Step 3 — The Trap of Temporarily Bypassing with 'setenforce 0', and How to Use It Correctly
In a panic, setenforce 0 makes the problem vanish like magic. That's because Permissive mode logs denials without blocking them. Leaving a production server in Permissive, though, is the same as dropping your security controls entirely. Lateral movement and file access that SELinux would have stopped during a compromise all become open.
The correct workflow is not "turn it off" — it's "open it briefly for diagnosis."
setenforce 0 # briefly Permissive for diagnosis
# Reproduce every blocked action once → collect all denial logs
ausearch -m avc -ts recent | audit2allow -M myapp
semodule -i myapp.pp # apply the collected policy for real
setenforce 1 # return to EnforcingPermanently disabling it with SELINUX=disabled in /etc/selinux/config is a last resort. Once you boot disabled, file labels stop being updated, so turning it back on later requires a full filesystem relabel (touch /.autorelabel then reboot) — a heavy operational cost. Given that SELinux is increasingly enabled on cloud images and container hosts, the right approach is "handle it, don't disable it."
A common case: Intermittent 403s after every deploy often happen because CI
rsyncs files and overwrites the context todefault_t. Adding a singlerestorecon -Rv /var/www/htmlat the end of the deploy script stopped the recurrence. A large share of SELinux problems aren't "grand policy work" — they end with one line of context restore.
Conclusion: Copy-Paste Command Cheatsheet
When permissions look fine but you still get denied, just follow this order.
| Symptom | Diagnosis | Fix |
|---|---|---|
| 403 / Permission denied (permissions look fine) | getenforce → Enforcing? | Continue below |
| Don't know which action was blocked | ausearch -m avc -ts recent | audit2why | Identify the cause branch |
File label is wrong (ls -Z) | tcontext is default_t or similar | restorecon -Rv /var/www/html |
| Non-standard web root | No label rule | semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" → restorecon -Rv |
| Non-standard port (8080) | tclass=tcp_socket | semanage port -a -t http_port_t -p tcp 8080 |
| External connection / DB blocked | getsebool -a | grep httpd | setsebool -P httpd_can_network_connect on |
| None of the above | Legitimate action still denied | ausearch -c 'nginx' --raw | audit2allow -M nginxlocal && semodule -i nginxlocal.pp |
Closing checklist: ① Confirm the culprit with getenforce → ② Collect logs with ausearch → ③ Apply the command for your case → ④ Always use -P with setsebool → ⑤ After diagnosis, return to setenforce 1.
References: Official Docs
The primary source for the behavior, settings, and errors covered in this post is the following official documentation. Check version-specific options and exact behavior there.
FAQ
Q. Can I just allow everything in the policy that audit2allow generates?
A. That's dangerous. audit2allow only generates rules that "allow every blocked action"; it does not judge whether those actions are legitimate. Always pipe to | audit2allow first, read which permissions it would open, and module only what your legitimate workload needs. Allowing denials that came from a compromise creates a security hole.
Q. I enabled it with setsebool, but it blocks again after reboot.
A. You most likely omitted -P. Without -P it applies only in runtime memory and resets on reboot. Make it permanent with setsebool -P ....
Q. Can't I just turn SELinux off?
A. It's convenient in the moment, but not recommended. RHEL 9, Rocky, and AlmaLinux 9/10 default to Enforcing, and SELinux use is growing in cloud and container environments. If you need to diagnose, briefly collect logs with setenforce 0, apply a policy, then return to setenforce 1. That's the safer workflow.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.