Web Development Bug Report

Django CSRF verification failed: Origin checking failed behind an HTTPS proxy

Every POST to our Django admin returned 403 after we moved TLS termination to a Traefik ingress. Django compared the browser's https Origin with a request it believed was plain http. Two settings fixed it without weakening CSRF.

Daniel Tran Daniel Tran @fun_dev · April 02, 2025 · 9 min read · 14 Views
Banner: Django CSRF Origin checking failed behind a TLS-terminating reverse proxy

Our logistics team runs a Django back office for warehouse staff. It used to sit on a VM with gunicorn behind nginx, where nginx also terminated TLS and proxied to 127.0.0.1:8000. In late March we moved it to Kubernetes, with a Traefik ingress terminating TLS and forwarding HTTP to the pod. GET requests worked. Every form submission, including logging into /admin/, failed with:

Forbidden (403)
CSRF verification failed. Request aborted.

With DEBUG off, the page tells you nothing else. The django.security.csrf logger was more useful:

WARNING 2025-03-28 09:41:06 django.security.csrf Forbidden (Origin checking failed - https://ops.example.com does not match any trusted origins.): /admin/login/

Reproducing it

Locally with runserver everything worked, because the browser and Django both used plain HTTP. We reproduced it with a small nginx container in front of runserver that terminated TLS with a self-signed certificate and passed X-Forwarded-Proto:

  1. docker compose up proxy app with nginx listening on 443.
  2. Open https://ops.localtest.me/admin/login/ and log in.
  3. 403 with the same "Origin checking failed" reason.

What we ruled out

  • Missing CSRF cookie. The csrftoken cookie was present in the browser and sent with the POST. A missing cookie gives a different reason: "CSRF cookie not set."
  • Wrong token in the form. That produces "CSRF token from POST incorrect." or "CSRF token missing.", not an Origin failure.
  • ALLOWED_HOSTS. A bad host raises DisallowedHost and a 400, not a 403. Our host was allowed.

Root cause

Django's CSRF middleware compares the browser's Origin header with the origin it believes the request was made to. The browser sent Origin: https://ops.example.com. Behind the ingress, Django received a plain HTTP connection, so request.is_secure() was False and Django computed the request's own origin as http://ops.example.com. The scheme did not match, the origin was not in CSRF_TRUSTED_ORIGINS, and the request was rejected. The old VM had the same HTTP hop between nginx and gunicorn, but its settings file declared SECURE_PROXY_SSL_HEADER. The settings module we wrote for the Helm chart had dropped it.

What is happening under the hood

Since Django 4.0, CsrfViewMiddleware checks the Origin header on unsafe requests (POST, PUT, PATCH, DELETE) when the browser sends one, and modern browsers send it on all of those. The check passes if either:

  • the Origin equals the request's own origin, built from request.scheme and request.get_host(), or
  • the Origin matches an entry in CSRF_TRUSTED_ORIGINS.

Two Django 4.0 changes catch people out:

Change in Django 4.0 Consequence
Origin header is checked, not only Referer Scheme mismatches now fail on plain form posts
CSRF_TRUSTED_ORIGINS entries must include the scheme ops.example.com must become https://ops.example.com

The middleware cannot know the original scheme on its own. TLS ended at the proxy; Django only sees HTTP. SECURE_PROXY_SSL_HEADER tells Django which forwarded header proves the original request was HTTPS, so request.is_secure() and request.scheme reflect reality.

Environment

Component Version / value
Runtime Python 3.12.9
Framework Django 5.1.7
App server gunicorn 23.0.0, 4 sync workers
Ingress Traefik v3.3 on Kubernetes 1.31, TLS terminated at the ingress
Forwarded headers X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-For set by Traefik
Relevant settings CSRF_TRUSTED_ORIGINS = [], no SECURE_PROXY_SSL_HEADER

Minimal reproduction

# settings.py (excerpt, broken)
ALLOWED_HOSTS = ["ops.example.com"]
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# no SECURE_PROXY_SSL_HEADER, no CSRF_TRUSTED_ORIGINS
# Simulate the proxy hop: Django sees HTTP, browser claims https Origin
curl -si http://127.0.0.1:8000/admin/login/ -c jar.txt -H 'Host: ops.example.com' >/dev/null
TOKEN=$(awk '/csrftoken/ {print $7}' jar.txt)
curl -si -b jar.txt http://127.0.0.1:8000/admin/login/ \
  -H 'Host: ops.example.com' -H 'Origin: https://ops.example.com' \
  -H 'X-Forwarded-Proto: https' \
  --data "csrfmiddlewaretoken=$TOKEN&username=x&password=y" | head -1
# HTTP/1.1 403 Forbidden

Who is affected

Django 4.0 and later behind any proxy that terminates TLS (nginx, Traefik, HAProxy, cloud load balancers, Cloudflare) when SECURE_PROXY_SSL_HEADER is not configured, and setups where the public hostname differs from what reaches Django, such as a frontend on app.example.com posting to an API on api.example.com.

The fix

We fixed the scheme, not the symptom. Telling Django how to recognise HTTPS made the same-origin check pass without adding any trusted origins:

# settings/production.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

ALLOWED_HOSTS = ["ops.example.com"]
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True

Django reads X-Forwarded-Proto from request.META["HTTP_X_FORWARDED_PROTO"]. When it equals https, request.is_secure() returns True, the computed origin becomes https://ops.example.com, and it matches the browser's Origin.

This setting is only safe if the proxy always sets or overwrites the header, so a client cannot send its own. We confirmed Traefik overwrites X-Forwarded-Proto for requests from untrusted sources. Our ingress entrypoint does not list any forwardedHeaders.trustedIPs, so client-supplied forwarded headers are replaced. We also made the pod reachable only through the ingress with a NetworkPolicy.

For a separate case, the internal React tool on https://tools.example.com that posts to this Django app with session cookies, we did need a trusted origin. Note the scheme:

CSRF_TRUSTED_ORIGINS = ["https://tools.example.com"]

A wildcard like https://*.example.com is supported, but it trusts every subdomain, including ones a different team or a SaaS vendor might control. We listed the exact origin.

Alternatives and trade-offs

  • Put the public origin in CSRF_TRUSTED_ORIGINS and skip the proxy header. It makes the 403 go away, but request.is_secure() stays False, so SECURE_SSL_REDIRECT would loop, absolute URLs from build_absolute_uri() are http://, and password reset emails link to HTTP.
  • USE_X_FORWARDED_HOST = True. Needed only if the proxy rewrites the Host header. Traefik passes the original Host by default, so we left it off; enabling it without need widens the trust you place in forwarded headers.
  • @csrf_exempt. Never for browser-facing form views. It removes the protection entirely.

How to test the fix

manage.py check --deploy flags missing secure-cookie and HSTS settings. It does not detect a missing proxy header, so we added a test:

# tests/test_csrf_proxy.py
from django.test import Client, TestCase, override_settings


@override_settings(ALLOWED_HOSTS=["ops.example.com"])
class CsrfBehindProxyTests(TestCase):
    def test_https_origin_accepted_via_forwarded_proto(self):
        client = Client(enforce_csrf_checks=True)
        client.get("/admin/login/", HTTP_HOST="ops.example.com",
                   HTTP_X_FORWARDED_PROTO="https")
        token = client.cookies["csrftoken"].value
        resp = client.post(
            "/admin/login/",
            {"csrfmiddlewaretoken": token, "username": "a", "password": "b"},
            HTTP_HOST="ops.example.com",
            HTTP_ORIGIN="https://ops.example.com",
            HTTP_X_FORWARDED_PROTO="https",
        )
        self.assertNotEqual(resp.status_code, 403)

    def test_foreign_origin_rejected(self):
        client = Client(enforce_csrf_checks=True)
        client.get("/admin/login/", HTTP_HOST="ops.example.com",
                   HTTP_X_FORWARDED_PROTO="https")
        token = client.cookies["csrftoken"].value
        resp = client.post(
            "/admin/login/",
            {"csrfmiddlewaretoken": token},
            HTTP_HOST="ops.example.com",
            HTTP_ORIGIN="https://attacker.example.net",
            HTTP_X_FORWARDED_PROTO="https",
        )
        self.assertEqual(resp.status_code, 403)
DJANGO_SETTINGS_MODULE=config.settings.production python manage.py test tests.test_csrf_proxy
python manage.py check --deploy

Confirming in production

  • The django.security.csrf logger went from about 2,300 warnings on the first day to 9 the day after the fix, all from stale tabs with expired tokens.
  • curl -sI https://ops.example.com/admin/ from outside returned Set-Cookie lines with Secure, and request.build_absolute_uri() in a Django shell under the live pod returned https:// URLs.
  • Password reset emails, which had silently started using http:// links after the migration, went back to https://.

Regression guard

  • The two tests above run in CI against the production settings module.
  • We moved proxy-related settings into one settings/proxy.py imported by every environment, so a new deployment target cannot drop them again.
  • A synthetic check logs into /admin/ through the public URL every ten minutes.

Security implications

The fix keeps CSRF protection fully on; it only gives Django correct information about the scheme. The risk lives in SECURE_PROXY_SSL_HEADER itself: if clients can reach Django directly, or the proxy passes through a client-supplied X-Forwarded-Proto, an attacker can make an HTTP request look secure. Make sure the proxy overwrites the header and that the app is not reachable around the proxy. Keep CSRF_TRUSTED_ORIGINS as short and exact as possible; every entry is a site allowed to submit authenticated forms to you.

Prevention checklist

  • Set SECURE_PROXY_SSL_HEADER whenever TLS ends before Django, and verify the proxy overwrites the header.
  • Always include the scheme in CSRF_TRUSTED_ORIGINS entries and avoid wildcards.
  • Log django.security.csrf warnings in production; they include the exact reason.
  • Run manage.py check --deploy in CI and a cross-origin CSRF test against production settings.
  • Keep proxy settings in a shared module so new environments inherit them.

References

Add a label

#csrf #django #python #reverse-proxy #https
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.