A 403 Forbidden from Nginx means the server understood your request, found the resource, and refused to serve it anyway. Nginx tells the browser almost nothing about why, which is what makes this frustrating, but it almost always records the real reason in its error log. The cause is nearly always one of a handful of things: file permissions, a missing index file, SELinux, or an explicit deny rule, with the occasional 403 that was actually returned by an upstream application. This walks through them in the order you should rule them out, with the commands to run and the log lines that tell you which one you're looking at.
Start by reading the error log
The single most useful thing you can do is stop guessing and read the log. Nginx writes the real reason to its error log, which defaults to /var/log/nginx/error.log. Reproduce the request and watch the log at the same time:
1sudo tail -f /var/log/nginx/error.log
Reload the page and you'll see a line that names the problem. The one you'll hit most often points at file permissions:
12026/06/30 14:03:11 [error] 28170#28170: *1 open() "/var/www/site/index.html" failed (13: Permission denied), client: 203.0.113.10, server: example.com, request: "GET / HTTP/1.1", host: "example.com"
The (13: Permission denied) is the key. Nginx tried to open the file and the operating system said no. You'll sometimes see the same problem worded as "...index.html" is forbidden (13: Permission denied), which means the same thing. The second common line looks similar but is a different problem:
12026/06/30 14:07:45 [error] 28170#28170: *3 directory index of "/var/www/site/" is forbidden, client: 203.0.113.10, server: example.com, request: "GET / HTTP/1.1", host: "example.com"
Notice there's no (13: Permission denied) here. This one means the URL resolved to a directory, there's no index file to serve, and directory listing is off. Different cause, different fix. Each section below has its own signature in this log, so match your line to the section.
If you reproduce the 403 and the error log stays completely silent, Nginx didn't refuse the request itself. The status is either an intentional return 403 in your config or a code passed straight through from an upstream, both covered near the end.
Fix file permissions and directory traversal
Permissions are the cause the large majority of the time, so start here if your log shows (13: Permission denied). Nginx's worker processes run as an unprivileged user, not as root. On Debian and Ubuntu that user is www-data; on RHEL, CentOS, Rocky, and Alma it's nginx. Confirm which one you have:
1ps -o user,comm -C nginx
123USER COMMANDroot nginxwww-data nginx
The root process is the master; the other one is the worker, and that's the user that actually needs to read your files. For a request to succeed, that user needs read permission on the file and execute (traverse) permission on every single directory in the path above it, all the way up to /. Missing the traverse bit on one parent directory is enough to cause a 403, and it's the part people forget.
The fastest way to see the whole chain is namei, which prints the permissions of every component of a path:
1namei -l /var/www/site/index.html
123456f: /var/www/site/index.htmldrwxr-xr-x root root /drwxr-xr-x root root vardrwxr-xr-x root root wwwdrwx------ deploy deploy site-rw-r--r-- deploy deploy index.html
Read down the list and look for any directory that doesn't grant the execute bit to "other". In the output above, site is drwx------, so only its owner can enter it. The worker user, which is neither the owner nor in the group, can't traverse into it, and the request fails before Nginx ever reaches the file. The fix is to make directories traversable and files readable:
12sudo find /var/www/site -type d -exec chmod 755 {} \;sudo find /var/www/site -type f -exec chmod 644 {} \;
755 on directories gives everyone execute (traverse) and the owner write; 644 on files gives everyone read and the owner write. Because these modes grant access to "other", the Nginx worker can read the files regardless of who owns them, as long as every parent directory up the tree is also traversable.
You don't have to hand ownership of your site to the Nginx user, and I'd avoid it. Ownership matters for writes, not reads, so keep your files owned by your deploy user and only grant the worker write access to the specific directories that genuinely need it, like an uploads folder. Making your entire web root owned and writable by the worker means a compromised Nginx can rewrite your site.
Add a missing index file or handle the directory
If the log said directory index of "..." is forbidden, Nginx resolved the request to a directory and had nothing to serve. By default it won't list a directory's contents, and with no index file present it returns a 403 rather than an empty page.
If this is a normal site, the fix is to make sure an index file actually exists in that directory and that Nginx knows its name through the index directive:
12345678910server {listen 80;server_name example.com;root /var/www/site;index index.html;location / {try_files $uri $uri/ =404;}}
The try_files line is the standard way to route requests: Nginx looks for the exact file, falls back to the directory, and returns a clean 404 for anything that doesn't exist. If you actually do want a browsable directory listing, for an internal file drop for example, enable autoindex for that location instead:
123location /downloads/ {autoindex on;}
Validate and reload after any config change:
1sudo nginx -t && sudo systemctl reload nginx
1nginx: configuration file /etc/nginx/nginx.conf test is successful
nginx -t catches syntax mistakes before they take your site down, so make it a habit before every reload.
Check SELinux on RHEL, CentOS, Rocky, and Alma
Here's the one that burns hours. On the RHEL family, SELinux adds a second permission system on top of the normal Unix one, and it can block Nginx even when your file permissions are flawless. The tell is a (13: Permission denied) in the log for a file whose ls -l output looks completely correct.
First check whether SELinux is even enforcing:
1getenforce
1Enforcing
If that says Enforcing, look at the SELinux type label on your files with ls -Z:
1ls -Z /var/www/site/index.html
1-rw-r--r--. deploy deploy unconfined_u:object_r:default_t:s0 /var/www/site/index.html
That default_t (or user_home_t if you're serving out of a home directory) is the problem. Nginx is only allowed to read files labeled with a web content type such as httpd_sys_content_t. You can confirm the denial in the audit log:
1sudo ausearch -m avc -ts recent | grep nginx
The fix is to set the correct context and apply it, not to turn SELinux off. Add a rule so the label survives a relabel, then restore it. On RHEL-family systems semanage ships in the policycoreutils-python-utils package, so install that first if the command isn't found:
12sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/site(/.*)?"sudo restorecon -Rv /var/www/site
If Nginx is proxying to a backend rather than serving files, SELinux blocks outbound connections by default too. Allow them with a boolean:
1sudo setsebool -P httpd_can_network_connect 1
You'll see plenty of advice to just run setenforce 0. Don't. It masks the real problem, it comes back on the next reboot, and it throws away the protection SELinux is giving you. Setting the right context takes the same amount of time and actually fixes it.
Look for an explicit deny rule in your config
If permissions and SELinux both check out, something in your configuration is refusing the request on purpose. The two directives that produce a 403 are an access rule from the deny family and an explicit return 403. The quickest way to find them across your whole config, including files pulled in by include, is to dump the merged config and grep it:
1sudo nginx -T 2>/dev/null | grep -nE 'deny|allow|return 403'
1242: deny all;58: return 403;
Those line numbers refer to positions in the merged nginx -T dump, not to any single file on disk, so use them to locate the directive within that combined output rather than jumping to the same line number in nginx.conf. When the access module is what blocked the request, the error log makes it obvious with a distinct message:
12026/06/30 14:12:03 [error] 28170#28170: *5 access forbidden by rule, client: 203.0.113.10, server: example.com, request: "GET /admin HTTP/1.1", host: "example.com"
The usual way to trip over this is an allow/deny block that was meant to restrict one path but ended up matching more than intended, or a leftover deny all; inside a location that's now serving real traffic. Check the surrounding location block for each hit and confirm the rule is doing what you meant. A quick note on a lookalike: HTTP Basic Auth (auth_basic) returns 401, not 403, so if you're chasing a 403 you can rule that one out.
Rule out a 403 from the upstream
If Nginx is a reverse proxy and its own error log is quiet, the 403 is coming from whatever sits behind it: a PHP-FPM process, a Node or Python app, an object store. Nginx is passing the status code through, and it's innocent.
Confirm by talking to the upstream directly, bypassing Nginx, and see whether the 403 still shows up:
1curl -i http://127.0.0.1:8080/the/path
1234HTTP/1.1 403 ForbiddenContent-Type: application/json{"error":"insufficient_scope"}
If you get a 403 straight from the backend, the fix is in the application, not in Nginx. Check the application's own log rather than Nginx's: WordPress writes to wp-content/debug.log when WP_DEBUG is on, Laravel to storage/logs/laravel.log, and most frameworks have an equivalent. For an API, a 403 with a body like the one above usually means an authorization or scope problem in the token, which you fix at the application layer.
Common pitfalls
The classic 403 that survives every permission fix is serving a site out of a home directory. You set the site directory to 755 and the files to 644, everything looks right, and it still fails, because /home/yourname itself is typically 750 or 700. The worker user can't traverse into it, so it never reaches your correctly-permissioned files. Run namei -l on the full path and the home directory shows up as the broken link in the chain. The clean fix is to serve from a location built for it, like /var/www or /srv/www. If you must serve from a home directory, chmod o+x /home/yourname grants traversal without exposing a directory listing, but moving the files is the better answer.
The other trap is reaching for chmod -R 777 when nothing else works. On the RHEL family this is doubly bad: it usually doesn't even fix the 403, because the real blocker is SELinux and not the mode bits, and now you've made your entire web root world-writable. If 777 appears to fix it on Debian, that only confirms the problem was a permission bit you can set correctly with 755 and 644. If it didn't fix it on RHEL, go back and check the SELinux context. Never leave 777 on a production web root.
Final thoughts
A 403 in Nginx almost always comes down to reading the error log, then working through file permissions, missing index files, SELinux context, and config-level deny rules, in that order. Once you've matched the log line to the cause, the fix is usually one or two commands. For the deeper mechanics of Nginx's access and error logs, including how to structure them as JSON, see Dash0's guide to mastering Nginx logs. If you're running Nginx as a Kubernetes ingress controller, Observing Ingress-NGINX with OpenTelemetry and Dash0 covers how to get full visibility into ingress traffic with traces, metrics, and logs.
Once the immediate problem is fixed, the thing worth setting up is a way to catch the next one before a user reports it. A sudden climb in 403s, or a surge of Permission denied lines in the error log, is exactly the kind of signal that's easy to miss until someone complains.
Dash0 parses your Nginx access and error logs out of the box with OpenTelemetry-native observability, correlates them with infrastructure metrics from the host and container, and ties both to distributed traces from the services behind your proxy, so you can spot a spike in 403s and jump straight to the request that caused it.
Start a free trial to see your Nginx logs, metrics, and traces in one place. No credit card required.