Skip to content

CSRF_TRUSTED_ORIGINS must carry a scheme

Severity: highBehaviour changed in Django 4.0Applies to: Django 4.0+Facts last verified 2026-07-26 against Django 6.0 (main)

Django 4.0 began consulting the Origin header in the CSRF middleware, and changed the format CSRF_TRUSTED_ORIGINS accepts. Entries that were bare hostnames before must now include a scheme, and a leading dot must become *..

Most material written before December 2021 shows the old form. Pasting one in produces a config Django refuses at check time, which is the good outcome. The bad one is a value that looks right, passes the check, and still rejects requests once a reverse proxy is in front of the app.

the fix
settings.py
CSRF_TRUSTED_ORIGINS = [
"https://app.example.com",
# ".example.com" before 4.0 — the leading dot becomes an explicit wildcard
"https://*.example.com",
]
verify it workedRun this in: framework cli
Terminal window
python manage.py check --deploy

The deploy check is the right home for this because it audits the rest of the deployment surface at the same time — CSRF_COOKIE_SECURE (security.W016) and a missing CsrfViewMiddleware (security.W003) both surface from the same run. It belongs in CI rather than in someone’s memory.

if you see this
Origin checking failed - %s does not match any trusted origins.

The request carried an Origin header that matched no entry. Most often the scheme is missing from the setting rather than the origin being genuinely untrusted.

Read in: django/middleware/csrf.py — REASON_BAD_ORIGIN
As of Django 4.0, the values in the CSRF_TRUSTED_ORIGINS setting must start with a scheme (usually http:// or https://) but found %s. See the release notes for details.

The system check caught the pre-4.0 format before the app served a request. It tests only for the absence of `://`, so it will not catch a stale leading-dot wildcard.

Read in: django/core/checks/compatibility/django_4_0.py — id 4_0.E001

Note what the check does not cover. It tests only for the absence of :// in each value, so a stale .example.com entry that was already migrated to https://.example.com passes the check and still never matches — the dot form needed to become *., and nothing warns about that.

before you ship this

If TLS terminates at a proxy and the application speaks plain HTTP behind it, Django sees http:// and the origin still will not match a https:// entry. That is a SECURE_PROXY_SSL_HEADER problem wearing a CSRF costume, and it needs the proxy to genuinely set the header Django is told to trust — otherwise a client can forge it.

Set both sides together and deploy them together. Changing only the Django side looks like the fix did nothing.

  • ALLOWED_HOSTS — the neighbouring setting whose wildcard syntax is the exact inverse of this one
  • SSL redirect behind a proxy — fixes the http:// mismatch described above
  • The other half of this failure belongs to the proxy: it has to send the scheme header Django is told to trust, and strip any copy the client supplied.