Fluxtail
Log Management Guides

Nginx 403 Forbidden: Diagnose and Fix the Exact Cause

Fix an Nginx 403 Forbidden error by tracing the response to permissions, index handling, access rules, paths, SELinux, symlinks, or an upstream.

By Fluxtail Engineering Updated

An Nginx 403 Forbidden response means something in the request path deliberately refused access, but the refusal may come from Nginx, an authorization subrequest, the upstream application, a WAF, or a CDN. Do not start with chmod or a reload. Reproduce one safe request, find the matching access and error records, and let the exact evidence choose the next branch.

Evidence Investigate next
open() ... failed (13: Permission denied) Unix ownership, mode bits, every parent directory, then SELinux or AppArmor
directory index of ... is forbidden Resolved directory, index, autoindex, and try_files
access forbidden by rule Matching server/location, allow/deny, method restrictions, or generated policy
Symlink-related refusal Resolved path and disable_symlinks
Access log has 403 but no local error reason auth_request, upstream application, WAF, or another response-producing module
Edge returns 403 and origin has no matching request CDN, load balancer, ingress, or front proxy

Reproduce one bounded request

Choose a known read-only URL that already fails. Send one GET request with a harmless correlation marker and discard the response body:

marker="nginx403-$(date +%s)"
request_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
curl --silent --show-error \
  --output /dev/null \
  --write-out 'status=%{http_code}\n' \
  "https://example.com/failing-path?diag=${marker}"
printf 'request_time=%s marker=%s\n' "$request_time" "$marker"

Do not add session cookies, bearer tokens, or customer identifiers unless the reproduction requires them and your incident procedure protects the command history and logs. A HEAD request is not a perfect substitute: applications and limit_except rules can treat HEAD differently from GET.

Keep four values together: UTC timestamp, host, method and URI, and marker. A request ID already added by your edge or application is even better. Response headers such as Server can offer a clue, but they are configurable and do not prove which layer generated the status. Inspect headers only when needed, and do not persist sensitive cookies or tokens in an incident artifact.

Correlate the access and error logs

Common package paths are /var/log/nginx/access.log and /var/log/nginx/error.log, but the active access_log and error_log directives are authoritative. Start with a small window rather than scanning every historical record:

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

On a systemd-managed host, the service may log to the journal instead of, or in addition to, files:

sudo journalctl -u nginx --since '10 minutes ago' --no-pager

For a container, inspect the runtime stream and the paths mounted by that deployment:

docker logs --since 10m NGINX_CONTAINER
kubectl logs --namespace NAMESPACE POD_NAME --container nginx --since=10m

Replace the uppercase names with the actual resources. Avoid broad log downloads when the output can contain authorization headers, query strings, cookies, or personal data.

The access record proves that a particular Nginx instance completed the request with status 403. The error record often explains a local filesystem or access-rule refusal. Nginx writes an access record in the location where processing ends, which can differ from the original location after an internal redirect. Correlate by time, request, client address, and request ID rather than assuming the first apparent location handled the final response. See Nginx's official HTTP log module documentation for this behavior.

If the active access format includes $upstream_status, compare it with $status: an upstream 403 is strong evidence that the proxied service refused the request. The predefined combined format does not include $upstream_status, so a missing value is not evidence that no upstream was called.

Confirm the active server, location, and configuration

Before reading individual files in /etc/nginx, confirm which binary and configuration are running:

nginx -V 2>&1
ps -eo user,pid,ppid,args | grep '[n]ginx'
sudo nginx -t

nginx -V reports the version, build options, and configured paths. nginx -t checks syntax and tries to open files referenced by the configuration. It does not prove that routing or authorization logic is correct.

nginx -T performs the same test and dumps the complete configuration, including included files:

sudo nginx -T

Use -T only in a restricted terminal. Its output may contain upstream credentials, embedded headers, private hostnames, certificate paths, or other secrets. Do not paste the dump into a ticket or public chat. The Nginx command-line reference documents the distinction between -t and -T.

For a container, run the binary inside the same image and pod that served the request:

docker exec NGINX_CONTAINER nginx -t
kubectl exec --namespace NAMESPACE POD_NAME --container nginx -- nginx -t

OpenResty, Nginx Proxy Manager, ingress controllers, and vendor images can generate configuration or use a different executable and prefix. Inspect the effective configuration inside the serving runtime, but make durable changes through its supported configuration source. Direct edits to generated files are commonly overwritten.

Nginx chooses a virtual server from the listening address, port, and Host header, then selects a location. Exact locations have priority; otherwise Nginx finds the longest prefix and normally evaluates regular-expression locations in their configured order, subject to modifiers such as ^~. Review the official request-processing explanation and location directive rather than reading only the visually nearest block.

Branch 1: Permission denied for a file or directory

When the error names a path and reports operating-system permission denied, inspect that exact resolved path. First identify the worker identity rather than assuming www-data or nginx:

ps -eo user,group,pid,args | grep '[n]ginx: worker process'

Then inspect every component from the filesystem root to the target:

namei -l /srv/example-site/public/index.html
stat -c '%A %a %U:%G %n' \
  /srv \
  /srv/example-site \
  /srv/example-site/public \
  /srv/example-site/public/index.html

The worker needs search (x) permission on every parent directory and read (r) permission on a static file. Listing a directory also requires read permission on that directory. A readable target file is still unreachable when one parent blocks traversal.

After recording the real worker user, test the intended access under that identity:

nginx_user=www-data
sudo -u "$nginx_user" test -x /srv/example-site/public
sudo -u "$nginx_user" test -r /srv/example-site/public/index.html

Set nginx_user to the observed worker identity. A successful test prints nothing and returns zero; check it explicitly if needed with echo $? immediately afterward.

Do not run recursive chown, recursive chmod, or chmod 777 as a generic repair. Those commands can transfer deployment ownership, expose secrets, make executable files writable, and hide the actual policy problem. Decide who should own deployed content, which group should read it, and which directories require traversal. Change only the incorrect component, then repeat the identity test.

If ordinary Unix permissions look correct, continue to the mandatory-access-control checks below. Root's ability to read a file does not prove that the confined Nginx worker can read it.

Branch 2: directory index ... is forbidden

This message means the URI resolved to a directory, no usable index response took over, and directory listing was not available. Nginx's autoindex is off by default and normally runs only for requests ending in / after the index module cannot find an index file.

Inspect the resolved directory from the error line, then compare it with the active directives:

server {
    root /srv/example-site/public;
    index index.html;

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

Check whether the requested directory actually contains a readable index.html, and remember that the index directive performs an internal redirect. That redirect can select a different location and its access rules.

Do not enable autoindex merely to suppress the 403. Directory listings disclose names, sizes, and paths and should exist only where listing is an intentional product requirement. If the URL should serve an application route, configure the application's documented fallback. If the URL should represent a real directory, provide the intended index or return an explicit status.

try_files checks candidates in order using the active root or alias. A trailing slash such as $uri/ intentionally checks whether a directory exists; it is not inherently wrong. It becomes relevant when that directory wins and later has no index or permitted listing. Keep it when real directories should take precedence. Remove or reorder it only when the application's routing contract says directories must not win. The official try_files documentation includes valid $uri/ examples.

Branch 3: access forbidden by rule

Search the effective server and included configuration for the location that handled the request. Relevant controls include:

  • allow and deny address rules;
  • satisfy all or satisfy any when more than one access module participates;
  • auth_request authorization subrequests;
  • limit_except method restrictions;
  • exact, prefix, or regular-expression locations that shadow another block;
  • generated WAF, ingress, or management-platform policy.

Nginx evaluates allow and deny rules in sequence until the first match. With the default satisfy all, every configured access mechanism must allow the request; satisfy any permits access when at least one succeeds. An auth_request subrequest allows on a 2xx result, denies with 401 or 403 on those results, and treats other response codes as errors. The module is not built by default, so verify build options and effective configuration before attributing a denial to it.

limit_except GET restricts methods other than GET and HEAD; allowing GET also allows HEAD. A browser GET may therefore work while POST or OPTIONS receives a refusal. Reproduce the exact method instead of testing only the URL.

Do not delete deny, authentication, or WAF rules simply because they caused the 403. First establish whether the caller should be allowed, which identity or network it represents, and which source owns the policy. A correctly enforced restriction is not an Nginx defect.

The primary references are the Nginx access module, satisfy, auth_request, and limit_except documentation.

Branch 4: root, alias, or trailing-slash path construction

root appends the request URI to its configured path. alias replaces the matching location prefix. These examples both map /images/logo.png to /data/w3/images/logo.png, but by different rules:

location /images/ {
    root /data/w3;
}
location /images/ {
    alias /data/w3/images/;
}

With alias, keep URI and filesystem trailing slashes aligned for directory locations. In a regular-expression location, alias should refer to captures that form the complete file path. Check the path printed in the Nginx error log; it shows what Nginx actually tried to open and is more useful than a guessed document root. The root and alias reference defines the path construction rules.

A wrong path does not always produce 404. If it resolves to an existing but unreadable directory, or a directory without an index, the final result can be 403. Fix the mapping that violates the intended URI-to-file relationship rather than opening permissions on the accidental target.

Branch 5: symlink policy

Resolve the target and display each path component:

readlink -f /srv/example-site/current/public/index.html
namei -l /srv/example-site/current/public/index.html

Then inspect the active disable_symlinks directive. Its default is off. With on, Nginx denies access when any checked path component is a symbolic link. With if_not_owner, it denies access when the link and its target have different owners. The optional from parameter changes where checking starts.

Do not replace or relax symlink policy until you understand why it exists. If a release system uses a current symlink, align the deployment layout, ownership, and narrowly scoped disable_symlinks policy. The Nginx symlink documentation also notes platform requirements and processing overhead.

Branch 6: SELinux blocks an otherwise readable path

On SELinux systems, Unix mode bits and ownership are only part of the decision. Check enforcement state, the target labels, and recent denials:

getenforce
ls -Zd /srv/example-site /srv/example-site/public
sudo ausearch -m AVC,USER_AVC,SELINUX_ERR,USER_SELINUX_ERR -ts recent
matchpathcon -V /srv/example-site/public/index.html

Correlate an AVC denial with the same reproduction time, Nginx process domain, target path, object type, and denied operation. Do not disable SELinux, switch the host to permissive mode as a routine fix, or create policy blindly from audit2allow output. Red Hat's SELinux troubleshooting guide recommends checking for labeling and configuration mistakes first.

For read-only website content on a RHEL-family system using the standard targeted web-server policy, a persistent file-context mapping may be appropriate after policy review:

sudo semanage fcontext -a -t httpd_sys_content_t '/srv/example-site(/.*)?'
sudo restorecon -Rv /srv/example-site

These are administrative configuration changes, not universal repair commands. Confirm that httpd_sys_content_t matches the intended access, use semanage fcontext -m instead of -a when updating an existing local mapping, and scope restorecon to the approved content tree. semanage fcontext records the persistent rule; restorecon applies the expected label. Avoid chcon for this workflow because its label change can be lost during relabeling. Red Hat documents this persistent approach in its non-standard web-directory example.

Branch 7: AppArmor denies the path

On Ubuntu and other AppArmor deployments, first confirm whether Nginx is confined and whether the kernel logged a matching denial:

sudo aa-status
sudo journalctl -k --since '10 minutes ago' | grep 'apparmor="DENIED"'

An AppArmor record identifies the profile, operation, path, and requested permission. Match it to the bounded request. Do not disable AppArmor or move a profile to complain mode as a default workaround. If access is intended, update the package-supported local profile override, review the exact path and permission, reload that profile through the deployment's approved process, and retest. Ubuntu's AppArmor documentation describes status, denial records, local profiles, and reviewed profile updates.

Branch 8: the 403 came from an upstream, WAF, or CDN

Not every page branded by Nginx was refused by the local filesystem. Work outward using correlated evidence:

  1. Origin Nginx error reason exists: follow that local branch.
  2. Origin access record has 403 but no local reason: inspect the matched proxy/FastCGI/auth configuration and the upstream log for the same timestamp or request ID.
  3. An existing access format shows upstream status 403: investigate application authorization, upstream routing, or its WAF policy.
  4. Front proxy records the request but origin does not: investigate CDN, load balancer, ingress, geo/IP policy, bot protection, and front-door authentication.
  5. Only one route or method fails: compare its exact location, method policy, and application authorization with a working control request.

An upstream application may intentionally return 403 for an authenticated user without permission. A WAF may block a request pattern. A CDN may reject a country, address, signature, or bot score before contacting the origin. Nginx Proxy Manager and ingress products may render the 403 while enforcing generated rules elsewhere. Preserve the responsible layer's log reason instead of forcing every case into file permissions.

Do not bypass a front-door security control by publishing an origin address or adding a broad allow rule. If an authorized origin-direct test is necessary, keep the Host header and TLS server name correct, restrict the test source, and follow the incident procedure.

Check containers and Kubernetes mounts

In containers, the identity and filesystem seen by Nginx may differ from the host. Inspect the running workload rather than only the image or manifest:

  • numeric worker UID and GID;
  • bind mount, volume, or ConfigMap path and mode;
  • readOnly mount and read-only root filesystem;
  • symlinks crossing mount boundaries;
  • SELinux labels or AppArmor profile applied by the runtime;
  • the generated Nginx configuration inside the container;
  • stdout/stderr logs and any file logs on mounted paths.

A read-only mount does not prevent ordinary static reads, but it blocks writes to that tree. Nginx or an upstream may need separate writable paths for temporary files, caches, uploads, PID files, or logs. Do not make the content mount writable until a log proves that a legitimate write is required.

Useful bounded checks include:

docker inspect NGINX_CONTAINER
docker exec NGINX_CONTAINER nginx -T
kubectl describe pod --namespace NAMESPACE POD_NAME
kubectl exec --namespace NAMESPACE POD_NAME --container nginx -- nginx -T

Both inspection outputs can expose environment values, mounted secret names, internal addresses, and full configuration. Review locally and sanitize before sharing. If a controller owns the pod, repair the Deployment, StatefulSet, Helm values, or controller configuration rather than editing the running container.

Validate the exact fix and reload safely

Make one minimal, reviewed change tied to the observed reason. For an Nginx configuration edit, validate before reload:

sudo nginx -t

If the test passes and the change is approved, use the service manager that owns the process. On a typical systemd package:

sudo systemctl reload nginx

For an Nginx master managed directly rather than by systemd:

sudo nginx -s reload

Containers and Kubernetes workloads should use their platform's configured reload or rollout mechanism. A successful reload does not prove the incident is fixed. Repeat the same bounded request, confirm the expected status and response, and verify that no new error appears. Also test a nearby route that must remain forbidden so the fix does not broaden access.

For a deeper configuration review, use the Nginx configuration checklist. For investigation technique beyond this status code, see how to read logs without losing context.

Centralize Nginx evidence without hiding the source

Local access, error, container, WAF, and application logs can rotate at different times. Central log management helps correlate them across hosts, but it does not replace the raw source or fix an unsafe log format. Avoid logging credentials and sensitive query values, preserve request IDs, and document which collector maps each native field.

Fluxtail log management is a paid, self-service destination with Starter and Pro plans. After you configure Nginx or a separate collector through a documented receiver path, Fluxtail provides named streams, Live Tail, alerts, and search/filter controls for the fields actually delivered. Parsing, severity, service, host, and label availability depend on the chosen log format and collector mapping; verify them with a harmless marker before relying on a filter.

Fluxtail's built-in AI chat and hosted MCP endpoint are separate investigation surfaces. Hosted MCP uses account-bound OAuth with PKCE. Read tools operate within the authorized account; operator mutations are proposed first and require a short-lived confirmation before application. Keep original Nginx and upstream records as evidence, and validate any AI or agent conclusion against those records.

If this fits your logging workflow, create a Fluxtail account, configure a supported receiver, and confirm one non-sensitive Nginx marker end to end.

Nginx 403 checklist

  • Reproduce one safe request and record its marker, UTC time, host, method, and URI.
  • Find the same request in every relevant edge, Nginx, upstream, and application log.
  • Confirm which layer generated 403 before changing permissions.
  • Inspect the effective server, location, includes, build, and runtime configuration.
  • Follow the exact error reason: filesystem traversal, missing index, access rule, path mapping, symlink policy, SELinux, AppArmor, or upstream denial.
  • Never use recursive ownership changes, 777, disabled mandatory access control, or a broad allow rule as diagnosis.
  • Treat $uri/ as a valid directory test and decide whether directories should win for this application.
  • Validate config with nginx -t; protect the potentially sensitive nginx -T output.
  • Reload only through the owning service or platform after an approved edit.
  • Retest both the allowed request and a control that must remain forbidden.