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.
- Look at the response body and headers: Cloudflare branding, an
nginxfooter, Django's "CSRF verification failed" page, or your own 403 template. - Nginx 403: check file and directory permissions on static and media paths, and
denyrules. - CSRF 403 on POST: set
CSRF_TRUSTED_ORIGINSwithhttps://, and make sure Django knows the request is HTTPS. - Django or DRF permission 403: check the user, groups, and permission classes; authentication may be failing silently.
- 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: cloudflarewith 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.
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.
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-Authenticateheader applies (token auth), and 403 otherwise. SECURE_CROSS_ORIGIN_OPENER_POLICYand 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
- Static file 403s in detail: Fix Nginx 403 Forbidden for Django Static Files.
- CSRF in detail: Fix CSRF Verification Failed in Django Production.
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.