Home/Security/How to Fix SELinux Permission Denied — When chmod Looks Fine but Access Is Still Blocked (avc denied)
SecuritySELinuxavc denied

How to Fix SELinux Permission Denied — When chmod Looks Fine but Access Is Still Blocked (avc denied)

If chmod and chown look fine but nginx or httpd still throw Permission denied, SELinux is the culprit. A practical, case-by-case guide to diagnosing avc denied with getenforce, ausearch, audit2allow, restorecon, and setsebool — plus copy-pa

How to Fix SELinux Permission Denied — When chmod Looks Fine but Access Is Still Blocked (avc denied)
Contents
  1. The Real Cause of 'Permission denied' That Survives chmod 777
  2. Step 1 — Confirm SELinux Is the Culprit in 30 Seconds
  3. Step 2 — Precise Fixes by Root Cause (The Core Section)
  4. Step 3 — The Trap of Temporarily Bypassing with 'setenforce 0', and How to Use It Correctly
  5. Conclusion: Copy-Paste Command Cheatsheet
  6. References: Official Docs
  7. FAQ

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. The first thing to suspect here is 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.

Bash
getenforce
# Enforcing  → SELinux is likely blocking you
# Permissive → logs only, does not block (suspect another cause)
# Disabled   → SELinux is not the culprit

If it's Enforcing, capture the denial logs. To watch them in real time:

Bash
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.

Bash
ausearch -m avc -ts recent      # last 10 minutes
ausearch -m avc -ts today       # all of today

Then translate why it was blocked into something a human can read.

Bash
ausearch -m avc -ts recent | audit2why

If setroubleshoot is installed, sealert will even suggest a friendly fix.

Bash
dnf install -y setroubleshoot-server
sealert -a /var/log/audit/audit.log

Here is where to look, by symptom.

SymptomWhat the log showsVerdict
Permission denied / 403 reading a filetclass=file, tcontext is default_t, user_home_t, etc.File context (①)
The service can't open its port{ name_bind }, tclass=tcp_socketPort label (②)
Reverse proxy 502, remote DB connection fails{ name_connect }Boolean or port type (③)
A container can't read a mounted volumetcontext is not container_file_tVolume label (Podman :Z/:z)

Anatomy of a Single avc: denied Log Line

A real log looks like this. The arrows show what to look at.

CODE
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:

Bash
ls -Z /var/www/html
# unconfined_u:object_r:default_t:s0  index.html   ← wrong

If it's a standard path, restoring the policy default is enough.

Bash
restorecon -Rv /var/www/html

Comparing the label the policy expects with the actual label settles it.

Bash
matchpathcon /var/www/html/index.html   # the label the policy expects (libselinux-utils)
restorecon -Rnv /var/www/html           # -n: change nothing, only print what would change

Changing a label with chcon fixes it for now, but if it differs from the policy rules it reverts on the next restorecon or full relabel. The permanent fix is to register a rule with semanage fcontext and apply it with restorecon.

If you moved the web root to a non-standard path like /data/www, register a default context rule for that path first, then restore.

Bash
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 port the policy doesn't cover, you'll get a tclass=tcp_socket, name_bind denial. First check whether the port already has a type, then label it.

Bash
semanage port -l | grep -w 8090        # no output means the port isn't defined yet
semanage port -a -t http_port_t -p tcp 8090

# A port already defined with another type, like 8080 (http_cache_port_t),
# fails with -a ("Port tcp/8080 already defined"), so change it with -m
semanage port -m -t http_port_t -p tcp 8080
semanage port -l -C                    # show locally modified port definitions

③ 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.

Bash
getsebool -a | grep httpd
setsebool -P httpd_can_network_connect on

Booleans that commonly get in the way:

BooleanPurposeCommand to enable
httpd_can_network_connectWeb server outbound connections to external hosts/APIssetsebool -P httpd_can_network_connect on
httpd_can_network_connect_dbWeb server connecting to a remote DB (MySQL/PostgreSQL)setsebool -P httpd_can_network_connect_db on
httpd_use_nfsUsing an NFS-mounted path as web contentsetsebool -P httpd_use_nfs on
httpd_enable_homedirsServing ~user/public_htmlsetsebool -P httpd_enable_homedirs on
nis_enabledBroad network access for NIS / external auth integrationsetsebool -P nis_enabled on

The -P flag is critical. Running setsebool without -P applies the change in memory only, so it reverts after a reboot. If "I turned it on, but it blocked again after restart," suspect a missing -P first. Permanent changes always need -P.

Container (Podman) volumes: if a container can't read a mounted directory, add :Z as in podman run -v /data:/data:Z to give it a label private to that container. For a directory shared by several containers, use :z (shared label). Don't use either on system directories like /home or /usr; it relabels them on the host.

④ 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.

Bash
# 1) First read what would be allowed
ausearch -c 'nginx' --raw | audit2allow
# 2) Collect only nginx denials and generate a module (nginxlocal.te / nginxlocal.pp)
ausearch -c 'nginx' --raw | audit2allow -M nginxlocal
cat nginxlocal.te            # review the allow rules by eye
# 3) Install only after reviewing
semodule -i nginxlocal.pp

After 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."

Instead of switching the whole system to Permissive, making just the problem domain permissive keeps every other service protected.

Bash
semanage permissive -a httpd_t     # only httpd_t (nginx/Apache) becomes permissive
# Reproduce the blocked actions to collect denials → fix them with ①–④ above
semanage permissive -d httpd_t     # always remove it once diagnosis is done
semanage permissive -l             # list permissive domains

If you can't pin down the domain, you can briefly switch the whole system to Permissive instead.

Bash
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 Enforcing

Permanently 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 to default_t. Adding a single restorecon -Rv /var/www/html at 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.

If you changed settings by hand on the server, record them in code with Ansible's community.general.sefcontext, ansible.posix.seboolean, and community.general.seport modules. Rebuilt servers then won't hit the same problem again.

Conclusion: Copy-Paste Command Cheatsheet

When permissions look fine but you still get denied, just follow this order.

SymptomDiagnosisFix
403 / Permission denied (permissions look fine)getenforce → Enforcing?Continue below
Don't know which action was blockedausearch -m avc -ts recent | audit2whyIdentify the cause branch
File label is wrong (ls -Z)tcontext is default_t or similarrestorecon -Rv /var/www/html
Non-standard web rootNo label rulesemanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" → restorecon -Rv
Non-standard porttclass=tcp_socket, name_bindUndefined port: semanage port -a -t http_port_t -p tcp 8090; already-defined 8080: use -m
External connection / DB blockedgetsebool -a | grep httpdsetsebool -P httpd_can_network_connect on
None of the aboveLegitimate action still deniedausearch -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.

Verification
✦ ✦ ✦
Sources and verification

Sources and checks vary by article. Only articles with a recorded check date, scope and sources below were checked, and only within that scope. For other articles, verify against the current docs for your version before applying anything, and email us about errors. Editorial policy · email us

This article has no recorded human check (date and scope) yet.

Published · ·Updated ·
Topic-related official docs (auto-linked · not per-claim sources)NGINX documentation ↗Linux man pages ↗OWASP documentation ↗

Comments

Be the first to comment.