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.
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:
docker compose up proxy appwith nginx listening on443.- Open
https://ops.localtest.me/admin/login/and log in. - 403 with the same "Origin checking failed" reason.
What we ruled out
- Missing CSRF cookie. The
csrftokencookie 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 raisesDisallowedHostand 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.schemeandrequest.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_ORIGINSand skip the proxy header. It makes the 403 go away, butrequest.is_secure()staysFalse, soSECURE_SSL_REDIRECTwould loop, absolute URLs frombuild_absolute_uri()arehttp://, and password reset emails link to HTTP. USE_X_FORWARDED_HOST = True. Needed only if the proxy rewrites theHostheader. Traefik passes the originalHostby 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.csrflogger 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 returnedSet-Cookielines withSecure, andrequest.build_absolute_uri()in a Django shell under the live pod returnedhttps://URLs.- Password reset emails, which had silently started using
http://links after the migration, went back tohttps://.
Regression guard
- The two tests above run in CI against the production settings module.
- We moved proxy-related settings into one
settings/proxy.pyimported 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_HEADERwhenever TLS ends before Django, and verify the proxy overwrites the header. - Always include the scheme in
CSRF_TRUSTED_ORIGINSentries and avoid wildcards. - Log
django.security.csrfwarnings in production; they include the exact reason. - Run
manage.py check --deployin CI and a cross-origin CSRF test against production settings. - Keep proxy settings in a shared module so new environments inherit them.
References
- Django docs: CSRF settings and
CSRF_TRUSTED_ORIGINS— https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins - Django docs:
SECURE_PROXY_SSL_HEADER— https://docs.djangoproject.com/en/5.1/ref/settings/#secure-proxy-ssl-header - Django docs: How CSRF protection works — https://docs.djangoproject.com/en/5.1/ref/csrf/
- Django 4.0 release notes: CSRF changes — https://docs.djangoproject.com/en/5.1/releases/4.0/
- Traefik docs: EntryPoints forwarded headers — https://doc.traefik.io/traefik/routing/entrypoints/
Add a label
Embed this post
<iframe src="https://hibug.me/django-csrf-origin-checking-failed-https-proxy" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.