CSRF Verification Failed in Django Production: How to Fix It
CSRF Verification Failed in Django production usually appears after a deployment change that affects domains, HTTPS, cookies, or proxy headers.
Problem statement
CSRF Verification Failed in Django production usually appears after a deployment change that affects domains, HTTPS, cookies, or proxy headers. The app may work locally and even serve pages normally in production, but form submissions, admin login, or other POST requests fail with 403 Forbidden.
This commonly happens when:
- you move from
localhostto a real domain - HTTPS is terminated at Nginx, Caddy, or a load balancer
- the app runs behind a reverse proxy
- the canonical host changes between
example.comandwww.example.com - the CSRF cookie is not being set or not being returned by the browser
Typical symptoms:
- Django admin login form fails in production
- GET requests work, but POST requests return 403
- sessions seem fine, but form submission fails
- the issue starts only after enabling HTTPS or adding a proxy
The goal is to find the exact failure reason and fix the deployment safely without weakening CSRF protection.
Quick answer
The most common fixes for CSRF verification failed in Django production are:
- make sure
ALLOWED_HOSTSincludes the active production hostnames - set
CSRF_TRUSTED_ORIGINSwith the full scheme, such ashttps://example.com - if TLS is terminated at a proxy, forward
X-Forwarded-Protoand setSECURE_PROXY_SSL_HEADER - verify the browser receives and sends the
csrftokencookie - confirm the request origin, host, redirect target, and scheme all match your real production URL
Safe order of operations:
- inspect logs first
- verify headers and cookies in the browser
- change one setting at a time
- test in staging first if possible
- keep a known-good settings snapshot for rollback
Step-by-step solution
1) Identify the exact CSRF failure reason
Start by checking Django logs and app server logs.
python manage.py check --deploy
For systemd deployments:
journalctl -u gunicorn -n 100 --no-pager
For Docker Compose:
docker compose logs web --tail=100
Django often logs more specific reasons than the browser shows, such as:
- bad origin
- referer checking failed
- CSRF cookie not set
- CSRF token missing or incorrect
If logs are too sparse, add production-safe logging for 403 and security events:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"django.security.csrf": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
},
}
Restart the app after changing settings:
sudo systemctl restart gunicorn
Or:
docker compose up -d --force-recreate web
Verification check: reproduce the 403 and confirm the logs now show whether it is an origin, referer, or cookie problem.
2) Verify production domain and HTTPS settings
Check ALLOWED_HOSTS first:
ALLOWED_HOSTS = [
"example.com",
"www.example.com",
]
Then check CSRF_TRUSTED_ORIGINS. In modern Django, this must include the full scheme:
CSRF_TRUSTED_ORIGINS = [
"https://example.com",
"https://www.example.com",
]
If you use subdomains, add the ones that actually serve forms:
CSRF_TRUSTED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
]
Do not assume ALLOWED_HOSTS replaces CSRF_TRUSTED_ORIGINS. They solve different problems:
ALLOWED_HOSTScontrols whichHostheaders Django acceptsCSRF_TRUSTED_ORIGINScontrols which origins are trusted for unsafe requests
Also check redirect behavior. If users load http://example.com, then get redirected to https://www.example.com, make sure your forms submit to the final canonical domain and not an earlier host.
Do not add extra origins just to suppress the error. Trust only the exact HTTPS origins that legitimately serve unsafe requests.
Verification check: open the form page in the browser and confirm the address bar host matches the configured canonical host. Then confirm your settings include that host and scheme.
3) Fix reverse proxy and TLS header handling
A very common cause of Django Nginx CSRF verification failed issues is that Django does not realize the original request was HTTPS.
If Nginx terminates TLS and proxies to Gunicorn on HTTP, forward the right headers:
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
If you redirect HTTP to HTTPS, keep host behavior consistent:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
In Django settings, trust the forwarded proto header:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Only set this when Django is behind a trusted reverse proxy
# that correctly sets and sanitizes X-Forwarded-Proto.
For secure cookie behavior in production:
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
Test Nginx config before reloading:
sudo nginx -t && sudo systemctl reload nginx
To inspect how Django interprets forwarded scheme headers at the app layer, a direct app-level test can help, but it does not replace testing through the real reverse proxy path:
curl -k -H "Host: example.com" -H "X-Forwarded-Proto: https" http://127.0.0.1:8000/
For Caddy, reverse_proxy usually forwards the required request information, but you should still verify the resulting headers and Django’s HTTPS detection.
Verification check: after reload and restart, reproduce the request and confirm CSRF logs no longer report bad origin or insecure referer mismatches.
4) Confirm CSRF cookie and session behavior
Now check whether the browser actually gets the CSRF cookie on the GET request that serves the form.
Use browser dev tools:
- open the Network tab
- load the login or form page
- inspect the response headers
- look for
Set-Cookie: csrftoken=...
A quick command-line check for response headers on a real GET request:
curl -s -D - -o /dev/null https://example.com/login/
If the cookie is missing, common causes include:
- the form page was cached incorrectly
- the view never rendered a CSRF token
- cookie domain settings are too restrictive
- HTTPS or cookie settings conflict with the real request path
Only set CSRF_COOKIE_DOMAIN if you truly need cross-subdomain cookie sharing. Otherwise, leave it unset. A bad value can break cookie delivery.
Example only when required:
CSRF_COOKIE_DOMAIN = ".example.com"
If your flow spans subdomains or embedded contexts, review SameSite behavior separately. For standard same-site Django forms, the default behavior is usually correct.
Also check that no CDN, WAF, or cache layer strips cookies or modifies headers on dynamic form pages.
Verification check: confirm the GET response sets csrftoken, and the subsequent POST request includes that cookie.
5) Check frontend and form submission details
For server-rendered Django templates, verify the form contains:
<form method="post">
{% csrf_token %}
<!-- fields -->
</form>
For AJAX requests, ensure the CSRF token is sent in the X-CSRFToken header and that the request is going to the correct HTTPS origin.
Also check for mixed-scheme mistakes:
- page loaded on
https://example.com - form posts to
http://example.com/...
That mismatch can trigger referer or origin failures in production.
If your frontend is truly cross-origin, treat it as a separate architecture problem. A same-origin Django template setup and a decoupled frontend are not configured the same way.
Verification check: inspect the failing POST in browser dev tools and confirm:
Originis correctRefereris correctcsrftokencookie is present- token field or header is present
- request target is the expected domain and scheme
6) Test the fix safely in production
After each change, test the full flow:
- load the form page
- confirm
csrftokenis set - submit the form
- watch logs during the POST
Useful checks:
journalctl -u gunicorn -n 100 --no-pager
docker compose logs web --tail=100
Test both:
- Django admin login
- one normal application form POST
If you changed proxy config, also confirm the canonical redirect path works correctly from HTTP to HTTPS and between www and apex if applicable.
7) Rollback and recovery notes
If the issue gets worse, revert in this order:
- proxy config changes
- recent Django security setting changes
- full app deployment to the previous known-good release
Before restoring proxy changes, validate the previous config file is still available and passes a syntax check:
sudo nginx -t
If you version your infrastructure config, restore the last known-good Nginx or Caddy revision rather than editing from memory.
Do not disable CSRF protection to get around a 403. That removes an important production security control and usually hides the real deployment problem.
Keep a known-good copy of:
- Django production settings
- Nginx or Caddy config
- environment variable values used for host and security settings
After rollback, re-run verification:
- admin login works
- normal form POST works
- no repeated CSRF warnings in logs
When to script this
If you deploy multiple Django apps or environments, this is a good candidate for automation. A reusable settings template, proxy template, and post-deploy smoke test can check ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, forwarded headers, and whether a form page returns a CSRF cookie. That reduces repeated manual mistakes without changing the core security model.
Explanation
Django CSRF protection validates several signals together:
- the CSRF cookie
- the submitted token
- the request origin or referer for secure requests
That is why production-only failures often point to deployment infrastructure, not just template code.
Reverse proxies and TLS termination are common root causes because Django may see an internal HTTP request unless the proxy forwards X-Forwarded-Proto and Django is configured to trust it. When that happens, referer and origin validation can fail even though the browser is using HTTPS correctly.
CSRF_TRUSTED_ORIGINS is also commonly misunderstood. It does not replace ALLOWED_HOSTS, and it must match the real browser-visible origin, including scheme.
Edge cases / notes
- Cross-subdomain apps: if forms move between
app.example.comandadmin.example.com, review cookie domain and trusted origins carefully. - Admin on a separate domain: include that exact HTTPS origin if admin POSTs happen there.
- Load balancers and platform proxies: confirm they preserve
Hostand forward protocol information correctly. - Docker deployments: keep these settings environment-driven, but validate that environment values match the real production domain.
- CDN or WAF interference: some layers cache login pages, strip headers, or alter cookie behavior. Bypass them temporarily for testing if needed.
- Redirect chains: unexpected redirects during login or form POST can switch host or scheme and cause false CSRF failures.
Internal links
If you need the underlying model first, read How Django CSRF Protection Works in Production.
For deployment configuration details, see Deploy Django with Gunicorn and Nginx and How to Configure HTTPS for Django Behind a Reverse Proxy.
For broader security debugging, see Django 403 Errors in Production: Common Causes and Fixes.
You may also need:
- How to Fix DisallowedHost in Django Production
- Django Deployment Checklist for Production
- Django 502 Bad Gateway: Causes and Fixes
FAQ
Why does Django CSRF fail only in production and not locally?
Local development often has no reverse proxy, no HTTPS termination, and simpler hostnames. Production adds real domains, redirects, secure cookies, and forwarded headers, which is where CSRF validation usually breaks.
Do I need both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS?
Yes, when your deployment needs both. ALLOWED_HOSTS validates the incoming host header. CSRF_TRUSTED_ORIGINS tells Django which origins are trusted for unsafe requests such as POST.
Can Nginx cause CSRF verification failures?
Yes. If Nginx does not forward Host or X-Forwarded-Proto correctly, Django can misread the request origin or scheme and reject valid POSTs.
Why is the CSRF cookie missing in production?
Common causes are incorrect cookie settings, cached form responses, missing {% csrf_token %} in rendered templates, or proxy, CDN, or WAF behavior that changes headers or cookies.
Should I ever disable CSRF protection to fix a 403?
No. Fix the host, origin, proxy, or cookie configuration instead. Disabling CSRF protection is not a safe production fix.