HTTP 403 Forbidden in Nginx and Apache: The Permission Checks I Actually Use

A 403 Forbidden response means the server understood the request but is refusing to allow it. That sounds straightforward, but the cause can sit in three very different layers: the web server configuration, the Linux filesystem permissions, or the application itself.

That layered view matters because 403 is one of the easiest errors to “fix” the wrong way. I still see people run chmod -R 777 on an entire web root or disable SELinux globally because a single site started returning 403. Those are the kinds of emergency changes that create security debt fast.

When I troubleshoot 403, I work from outside to inside:

  1. confirm whether the web server is denying access intentionally
  2. verify the requested path and index handling
  3. inspect Linux ownership and directory traversal permissions
  4. check MAC systems like SELinux or AppArmor
  5. confirm the application itself is not returning 403 deliberately

If your issue turns out to be a redirect or rewrite problem rather than true access denial, HTTP 301 vs 302 redirects in Nginx and Apache and Apache mod_rewrite redirects and HTTPS enforcement are better starting points.

What 403 actually means

A 403 is not “file not found.” That would usually be 404. It is not “authentication required.” That is usually 401. A 403 says the request was valid, but the server refuses access.

Common real-world examples:

  • directory listing disabled and no index file exists
  • explicit deny all or IP restrictions
  • file permissions prevent the web server from traversing the path
  • SELinux or AppArmor blocks access
  • Apache Require all denied or missing Require all granted
  • Nginx try_files points into a path that is not readable
  • application authorization logic returns 403 after reading the request correctly

That is why I keep the three-layer model in mind.

Layer 1: web server configuration causing 403

This is the first layer I inspect because it is often the cleanest explanation.

Nginx: missing index file with autoindex off

One classic case is requesting a directory when directory listing is disabled and there is no index file.

Example:

server {
    listen 80;
    server_name example.com;
    root /var/www/site;
    index index.html index.php;

    location / {
        try_files $uri $uri/ =404;
    }
}

If /var/www/site/docs/ exists but has no index.html and autoindex is off, Nginx may return 403 for the directory request.

Check the error log:

sudo tail -n 100 /var/log/nginx/error.log

A common message:

directory index of "/var/www/site/docs/" is forbidden

In that case, the server is behaving exactly as configured.

Nginx: explicit deny rules

I often find old deny stanzas forgotten in a config include:

location /admin {
    deny all;
}

Or IP-based restrictions:

location /internal/ {
    allow 192.0.2.10;
    deny all;
}

Inspect the rendered config if includes are involved:

sudo nginx -T | grep -n 'deny\|allow\|internal\|satisfy'

Nginx: try_files and root/alias mistakes

root and alias mistakes can produce confusing access failures.

Example of a common alias pitfall:

location /assets/ {
    alias /var/www/assets;
}

The trailing slash behavior matters. A mismatch there can send Nginx to the wrong filesystem path.

Validate the effective path and permissions, not just the directive itself.

Apache: Require directives

Modern Apache uses authorization directives such as:

<Directory /var/www/site/public>
    Require all granted
</Directory>

If a directory block instead contains:

Require all denied

or lacks the expected grant in a restrictive setup, you get 403.

Apache 2.2-era patterns like Order allow,deny and Allow from all are legacy. They still appear on old systems, but current Apache practice is using Require syntax consistently.

Validate config:

sudo apachectl configtest

Inspect virtual host config and active modules:

sudo apachectl -M | sort

Apache: Options and AllowOverride

A bad .htaccess dependency or restrictive Options set can also lead to access issues, especially on older shared-hosting style setups.

I do not blame .htaccess first, but I always check whether the site depends on rewrite or directory options that are not actually allowed.

Layer 2: Linux file permissions and ownership

This is where many 403 incidents really live.

The web server process needs permission not only to read the target file but also to traverse every parent directory. That is why ls -l index.html alone often misleads people.

Use namei -l instead of guessing

This is one of the most useful commands for 403 debugging:

namei -l /var/www/site/public/index.html

Example output may show something like:

f: /var/www/site/public/index.html
drwxr-xr-x root root /
drwxr-xr-x root root var
drwxr-x--- deploy deploy www
drwxr-xr-x deploy deploy site
drwxr-xr-x deploy deploy public
-rw-r--r-- deploy deploy index.html

In that example, the www directory is not traversable by the web server user if it is not in the deploy group. The file itself can be world-readable and still fail.

Check which user the web server actually runs as

On Debian and Ubuntu, Nginx and Apache often run as www-data. On RHEL-family systems, Apache may run as apache, and Nginx may use nginx.

Check the service or process list:

ps aux | egrep 'nginx|apache2|httpd' | head

Then make sure ownership and mode align with that reality.

Sensible permission baselines

For standard static content, I usually expect:

  • directories: 755
  • files: 644
  • owner/group chosen to fit the deployment model

Examples:

sudo find /var/www/site -type d -exec chmod 755 {} \;
sudo find /var/www/site -type f -exec chmod 644 {} \;

I do not use that blindly on writable application directories. Apps often need specific writable paths for cache, sessions, uploads, or logs.

Why chmod 777 is the wrong fix

It is tempting because it often makes the error disappear. It is also a terrible habit.

Problems with 777:

  • any local user or compromised process can modify files
  • it hides the real ownership model
  • it can enable defacement or code injection more easily
  • it becomes hard to audit what should actually be writable

Current practice is assigning ownership and group access intentionally. 777 is an outdated shortcut I avoid.

If you are tightening host permissions more broadly, Linux server hardening practical checklist and SSH key authentication on Linux fit the same operational mindset.

Layer 3: application-level 403 responses

A lot of “Nginx 403” reports are not Nginx at all. The app read the request and denied it.

Examples:

  • admin area requires login and returns 403
  • CSRF validation fails
  • application checks Host or X-Forwarded-Proto and rejects unexpected values
  • API token missing or invalid
  • framework authorization policy denies access

The giveaway is often that Nginx access logs show 403 but Nginx error logs are silent. If the application logs show an authorization exception, I stop chasing file permissions.

This comes up often when proxy headers are incomplete. Compare your setup to Nginx reverse proxy with SSL termination if the application sits behind another layer.

SELinux and AppArmor denials

I have seen perfectly correct Unix permissions still produce 403 because SELinux or AppArmor blocked access.

SELinux

On SELinux systems, check mode first:

getenforce

Then inspect denials:

sudo ausearch -m avc -ts recent

Or review audit logs:

sudo grep denied /var/log/audit/audit.log | tail -n 20

If the context is wrong on web content, that matters more than traditional mode bits. For typical web content on RHEL systems, I verify labels with:

ls -lZ /var/www

And restore default context if appropriate:

sudo restorecon -Rv /var/www/site

I avoid setting SELinux to permissive unless I am isolating a diagnosis carefully and temporarily.

AppArmor

On Ubuntu systems, AppArmor may restrict service access.

Check status:

sudo aa-status

Then inspect kernel and journal messages:

sudo journalctl -k -n 100 --no-pager

Again, I prefer understanding the rule and path restriction instead of disabling the entire protection layer.

PHP-FPM pool user mismatches

This one causes surprisingly messy behavior. Nginx might be able to serve static files, but PHP-backed requests fail or behave inconsistently because PHP-FPM runs under a different user or cannot access required paths.

Inspect the pool configuration:

grep -E '^(user|group|listen|listen.owner|listen.group|listen.mode)' /etc/php/8.2/fpm/pool.d/www.conf

Typical values on Debian/Ubuntu:

user = www-data
group = www-data
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

If application files are owned for a different deployment user and lack group/world readability where needed, PHP may return 403-like access problems or 500-class failures depending on the exact path.

For the bigger PHP side of that stack, PHP-FPM with Nginx configuration and tuning is the reference I keep around.

Root, alias, and document-root mistakes that look like permission problems

A surprising number of 403 reports start as path-mapping mistakes. The permissions may be correct, but the web server is looking in the wrong place and applying directory logic you did not expect.

In Nginx, root appends the request URI to the configured path, while alias replaces the matching location prefix. If those are used interchangeably, you can end up checking the wrong file tree for hours.

Example with root:

location /images/ {
    root /var/www/site;
}

A request for /images/logo.png maps to /var/www/site/images/logo.png.

Example with alias:

location /images/ {
    alias /srv/shared-assets/;
}

That same request maps to /srv/shared-assets/logo.png.

If I see a 403 on a path served through alias, I verify the actual on-disk mapping immediately. A wrong assumption there can make a normal directory-index or permission denial look much stranger than it really is.

Apache has similar problems when the wrong DocumentRoot, Alias, or <Directory> block is being matched. I use:

sudo apachectl -S

That shows which virtual host Apache believes owns the request and helps explain why a directory rule you expected is not taking effect.

Directory traversal, symlinks, and deployment layouts

Modern deployments often use symlinks, release directories, or shared storage. Those layouts are fine, but they add one more place where 403 can appear.

Things I check:

  • does the web server have execute permission on every real parent directory in the symlink target path?
  • if Apache uses Options FollowSymLinks or SymLinksIfOwnerMatch, does the chosen policy match the deployment model?
  • is a shared upload directory mounted with the expected owner and mode after reboot?

For symlink-heavy paths, namei -l is still my first tool because it shows the full traversal clearly. I also confirm the resolved path:

readlink -f /var/www/site/current/public

I have seen deployments where /var/www/site/current pointed to the new release but a shared upload directory was mounted with tighter permissions than the previous release expected. The application reported 403 on file access, yet the web root itself looked fine.

When 403 is really a front-controller routing problem

Framework apps sometimes return 403 because the request never reaches the intended front controller correctly. A missing index.php, broken try_files, or incorrect rewrite can push the request into a directory access path instead of the application router.

A common Nginx pattern for PHP apps is:

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

If that is changed carelessly, requests that should hit the app may instead hit a physical directory and return 403 because no index is allowed there. In Apache, a missing or inactive rewrite module can create similar confusion.

That is why I validate both the filesystem and the routing behavior. A 403 can be about authorization, but it can also mean the server fell off the intended request path and landed in a forbidden directory context instead.

Common 403 mistakes I run into

Missing index file mistaken for “Nginx is broken”

If the error log says directory index is forbidden, the fix is usually index handling or routing, not a restart loop.

Fixing the file but not the parent directories

Traversal permission on the full path matters.

Looking only at Unix permissions and forgetting SELinux/AppArmor

This wastes time on distributions where MAC is enforced.

Blaming the web server for app-generated authorization failures

Read the app logs.

Using chmod -R 777

It is still wrong, even when you are in a hurry.

Validation commands I use after a fix

After adjusting config or permissions, I validate both syntax and effective behavior.

Nginx

sudo nginx -t
sudo systemctl reload nginx
curl -I https://example.com/protected-path

Apache

sudo apachectl configtest
sudo systemctl reload apache2
curl -I https://example.com/protected-path

Filesystem and labels

namei -l /var/www/site/public/index.html
ls -lZ /var/www/site/public/index.html

My repeatable 403 checklist

1. Reproduce the error

curl -I https://example.com/

2. Read the web server error log

sudo tail -n 100 /var/log/nginx/error.log

or

sudo tail -n 100 /var/log/apache2/error.log

3. Inspect active deny rules and directory handling

sudo nginx -T | grep -n 'deny\|allow\|autoindex\|try_files'
sudo apachectl -S

4. Check path traversal and ownership

namei -l /var/www/site/public/index.html

5. Confirm the service user

ps aux | egrep 'nginx|apache2|httpd' | head

6. Check SELinux/AppArmor if applicable

getenforce
sudo aa-status

7. If only app paths fail, inspect app logs

sudo journalctl -u myapp.service -n 100 --no-pager

Validate with the exact URL and user path that failed

A final detail I pay attention to is reproducing the same request path the user hit. A homepage test may work while a nested upload path, language-prefixed URL, or symlinked asset directory still returns 403. I test the specific URL with curl -I, then match it to the exact filesystem path or app route involved. That keeps me from declaring victory after fixing only the most obvious case.

One small mismatch in a parent directory can still block everything beneath it, so I always verify the whole chain.

Conclusion

A 403 Forbidden response is usually straightforward once you stop treating it as a single category. It comes from one of three places: the web server intentionally denies access, Linux permissions or MAC policy block the path, or the application itself refuses the request. The fastest route is to read the error log, inspect the full path with namei -l, verify the service user, and only then touch ownership or policy. In my experience, careful permission fixes and clean config review solve 403 problems much more reliably than panic-driven permission changes ever do.

Scroll to Top