Fix Django 403 Forbidden Errors in Production

Fix 403 Forbidden errors on a Django site in production: tell Nginx 403s from Django 403s, fix CSRF failures, file permissions on static and media, permission checks in views, and WAF or Cloudflare blocks.

Problem statement

Users get 403 Forbidden in production on pages, form submissions, API calls, or files that work in development. A 403 means "the server understood the request and refuses it", but in a typical stack four different layers can refuse: a WAF or CDN such as Cloudflare, Nginx (file permissions or access rules), Django's CSRF protection, and your own permission checks in views or Django REST Framework. Each produces a 403 that looks different once you know where to look.

Four layers that can say 403CDN / WAFbot or rule blockNginxfiles or deny rulesDjango CSRFPOST without tokenView permissionsuser not allowedIdentify which layer answered from the page body and the logs before changing anything.
  1. Look at the response body and headers: Cloudflare branding, an nginx footer, Django's "CSRF verification failed" page, or your own 403 template.
  2. Nginx 403: check file and directory permissions on static and media paths, and deny rules.
  3. CSRF 403 on POST: set CSRF_TRUSTED_ORIGINS with https://, and make sure Django knows the request is HTTPS.
  4. Django or DRF permission 403: check the user, groups, and permission classes; authentication may be failing silently.
  5. WAF or Cloudflare 403: check firewall events and adjust the rule.

Step-by-step solution

1. Identify the layer

curl -sI https://example.com/path/ | grep -iE "server|cf-ray|content-type"
curl -s https://example.com/path/ | head -20
  • server: cloudflare with a Cloudflare error page and a Ray ID → Cloudflare WAF or bot rules.
  • <center>nginx</center> in the body → Nginx itself.
  • "Forbidden (403) CSRF verification failed" → Django's CSRF middleware.
  • Your site's template or {"detail": "You do not have permission to perform this action."} → view or DRF permissions.

2. Nginx 403: permissions and rules

The Nginx error log names the file:

open() "/srv/app/staticfiles/css/site.css" failed (13: Permission denied)
directory index of "/srv/app/media/" is forbidden

Nginx workers (user www-data or nginx) need execute permission on every directory in the path and read permission on files:

namei -l /srv/app/staticfiles/css/site.css
sudo chmod o+x /srv /srv/app
sudo chmod -R o+rX /srv/app/staticfiles

"Directory index is forbidden" means a request for a directory without an index file; that is expected and harmless for /media/. Also check for deny all; or allow lists in the matching location, and SELinux contexts on RHEL-family systems.

read the response to find the layerWhat the 403 looks likeLayer and fixCloudflare page with Ray IDWAF or bot rule; check firewall eventsnginx footer, static or media URLfile permissions or deny ruleCSRF verification failedCSRF_TRUSTED_ORIGINSand HTTPS awarenessDRF detail JSONpermission class orfailed authenticationyour 403 templateview decorator or object permissionThe body of the 403 response names the layer that refuseddjango-deployment.com
Fixing the wrong layer changes nothing, so identify it first.

3. CSRF 403s on forms and logins

Django 4.0+ checks the Origin header against CSRF_TRUSTED_ORIGINS for HTTPS requests. Behind a proxy:

CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True

The full list of CSRF causes, including cached pages with stale tokens and cross-domain frontends, is in Fix CSRF Verification Failed in Django Production.

4. View and DRF permission 403s

In DRF, a 403 often means authentication failed rather than authorisation: if the token or session is not recognised, the user is anonymous, and IsAuthenticated returns 403 (or 401 with token auth). Check:

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
}

Common production-only causes: the Authorization header stripped by a proxy (Apache mod_wsgi needs WSGIPassAuthorization On), session cookies not sent because of SameSite or a different domain, or permissions and groups not created in the production database because they were set up manually in development.

5. WAF and CDN blocks

Cloudflare's Security Events log shows the rule, IP, and path for each block. Rules that inspect bodies can flag legitimate admin form posts with HTML or SQL-like content. Add a targeted skip rule for authenticated admin paths, or tune the managed rule, rather than disabling the WAF.

clearing a 403 layer by layerLayer identified from body and headersCDN, Nginx, CSRF, viewStatic and media paths readable by Nginxnamei -l to check each dirCSRF_TRUSTED_ORIGINS with https://and proxy SSL headerAuth headers reach DjangoAuthorization not strippedWAF events reviewed, rule tunednot disabledEach item clears one layer that can refuse a requestdjango-deployment.com
Work through the layers from the outside in.

Verification check: repeat the failing request with curl -i (including cookies or the auth header) and confirm a 200 or expected redirect; check the Nginx error log and Django logs show no new 403 reasons for legitimate requests.

Explanation

HTTP 403 is a generic refusal, and every component in a web stack uses it for its own reasons. A CDN refuses traffic matching security rules, Nginx refuses when the operating system denies file access or a configured rule matches, Django's CSRF middleware refuses unsafe requests without a valid token and origin, and application code refuses users without permission. The visible page and the log that records the refusal differ for each, which makes identifying the layer the fastest route to the fix.

Edge cases / notes

  • 401 vs 403. DRF returns 401 when authentication is required and a WWW-Authenticate header applies (token auth), and 403 otherwise.
  • SECURE_CROSS_ORIGIN_OPENER_POLICY and other headers do not cause 403s; they affect browser behaviour after a successful response.
  • Hotlink protection in CDNs returns 403 for media embedded on other sites.
  • Rate limiting usually returns 429, but some configurations use 403.

Next steps

FAQ

How do I know whether Nginx or Django returned the 403?

An Nginx 403 has an nginx footer and an entry in the Nginx error log; Django's has its own page or JSON and a Django log entry.

Why do POST requests return 403 only in production?

Usually CSRF: missing CSRF_TRUSTED_ORIGINS entries or Django not knowing the request is HTTPS behind the proxy.

Why does DRF return 403 for logged-in users?

The request is probably not authenticated as you think: a stripped Authorization header or cookies not sent.

Can Cloudflare cause 403s?

Yes. Its WAF and bot rules return 403 pages with a Ray ID; check Security Events.

Keep going