Troubleshooting
#django

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 localhost to 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.com and www.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:

  1. make sure ALLOWED_HOSTS includes the active production hostnames
  2. set CSRF_TRUSTED_ORIGINS with the full scheme, such as https://example.com
  3. if TLS is terminated at a proxy, forward X-Forwarded-Proto and set SECURE_PROXY_SSL_HEADER
  4. verify the browser receives and sends the csrftoken cookie
  5. 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_HOSTS controls which Host headers Django accepts
  • CSRF_TRUSTED_ORIGINS controls 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.

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:

  • Origin is correct
  • Referer is correct
  • csrftoken cookie 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:

  1. load the form page
  2. confirm csrftoken is set
  3. submit the form
  4. 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:

  1. proxy config changes
  2. recent Django security setting changes
  3. 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.com and admin.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 Host and 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.

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:

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.

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.

2026 · django-deployment.com - Django Deployment knowledge base