Fix "CSRF_TRUSTED_ORIGINS Must Start With a Scheme" in Django

Fix the Django 4.0+ system check error 4_0.E001, which says CSRF_TRUSTED_ORIGINS values must start with a scheme: write full origins with https://, use wildcard subdomains correctly, parse the setting from environment variables, and avoid trailing slashes.

Problem statement

After upgrading to Django 4.0 or later, or after copying settings from an older project, manage.py refuses to start:

SystemCheckError: System check identified some issues:

ERRORS:
?: (4_0.E001) As of Django 4.0, the values in the CSRF_TRUSTED_ORIGINS setting
must start with a scheme (usually http:// or https://) but found example.com.
See the release notes for details.

Gunicorn workers fail to boot with the same message, and migrate or collectstatic stop in CI. Before Django 4.0, CSRF_TRUSTED_ORIGINS held bare host names such as example.com. Since 4.0 it holds full origins: a scheme, a host, and an optional port, exactly as a browser sends them in the Origin header. Django checks the format at startup so a wrong value fails loudly instead of rejecting every form post.

Why the check failsSetting valueexample.comDjango 4.0+ checkexpects an origin4_0.E001startup stopsAdd the schemehttps://example.comOrigins are compared with the browser Origin header, which always includes the scheme.
  1. Prefix every entry with its scheme: CSRF_TRUSTED_ORIGINS = ["https://example.com"].
  2. For subdomains use "https://*.example.com", not ".example.com".
  3. Include a port only when it is non-standard: "http://localhost:8000".
  4. Never add a path or trailing slash: "https://example.com/" does not match.
  5. If the value comes from an environment variable, split it on commas and strip spaces before Django sees it.

Step-by-step solution

1. Find where the value is set

Search the settings package and the environment, because many projects build the list from a variable:

grep -rn "CSRF_TRUSTED_ORIGINS" config/ settings/ 2>/dev/null
python manage.py diffsettings | grep CSRF_TRUSTED_ORIGINS

diffsettings prints the final value after all imports and environment parsing, which is what the check sees. A value such as ['example.com', 'www.example.com'] is the old style.

2. Rewrite the entries as origins

# settings/production.py
CSRF_TRUSTED_ORIGINS = [
    "https://example.com",
    "https://www.example.com",
    "https://*.example.com",   # any subdomain over HTTPS
]

Each entry is compared with the Origin header of unsafe requests (POST, PUT, PATCH, DELETE). The browser sends https://www.example.com, never www.example.com, so the scheme has to be part of the setting. A wildcard is allowed only at the start of the host part, after the scheme.

what Django 4.0 and later acceptsRejected or never matches✕example.com✕.example.com✕https://example.com/Valid origins✓https://example.com✓https://*.example.com✓http://localhost:8000Scheme, host, optional port, and nothing after itdjango-deployment.com
Write each entry exactly as a browser would send it in the Origin header.

3. Parse environment variables carefully

A common way to break the check is a variable like CSRF_TRUSTED_ORIGINS="https://example.com, https://www.example.com" parsed with a plain split(","). The second item then starts with a space and the check reports found https://www.example.com. Strip each item and drop empty ones:

import os

CSRF_TRUSTED_ORIGINS = [
    origin.strip()
    for origin in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
    if origin.strip()
]

With django-environ, env.list("CSRF_TRUSTED_ORIGINS", default=[]) already strips whitespace. Store full origins in the variable on every platform: Heroku config vars, Render environment groups, Kubernetes ConfigMaps, and .env files.

4. Derive origins from ALLOWED_HOSTS only with a scheme

Some settings files build the list from ALLOWED_HOSTS. That is fine as long as the scheme is added and the special values are skipped:

CSRF_TRUSTED_ORIGINS = [
    f"https://{host.lstrip('.')}" if not host.startswith(".") else f"https://*{host}"
    for host in ALLOWED_HOSTS
    if host not in ("*", "localhost", "127.0.0.1")
]

Keep it simple when you can. An explicit list is easier to review than generated values.

5. Re-run the checks

python manage.py check --deploy

The 4_0.E001 error should be gone. Then submit a real form, such as the admin login, over HTTPS to confirm the origin matches.

Explanation

Django 4.0 changed CSRF protection to compare the Origin header first, falling back to Referer only when Origin is missing. An origin is a scheme plus host plus port, so the trusted list had to change shape to match it. The check exists because the old format would otherwise fail silently: every cross-origin POST would get a 403 with "Origin checking failed" and no hint that the setting was the cause.

You only need CSRF_TRUSTED_ORIGINS for origins that differ from the request's own host. A same-origin form on https://example.com posting to https://example.com passes without it, provided Django knows the request was HTTPS. Behind a proxy that terminates TLS, that means setting SECURE_PROXY_SSL_HEADER; otherwise Django thinks the request was HTTP, the origin https://example.com looks foreign, and you get the 403 even with a correct list.

origin checking in Django 4.0 and later1Browser sends Origin headerscheme://host:port2Compare with request hostneeds correct scheme3Else check trusted listCSRF_TRUSTED_ORIGINS4Then check the CSRF tokencookie and form value5Request acceptedor 403 ForbiddenThe trusted list is consulted only when the origin differs from the requestdjango-deployment.com
If Django gets the scheme wrong behind a proxy, even same-site posts fall through to the trusted list.

Edge cases / notes

  • Mixed Django versions. A settings file shared by a Django 3.2 service and a 4.x service can list both formats, but 3.2 matches only hosts and 4.x only origins. Upgrade both or keep separate settings.
  • Non-default ports. A staging site on https://staging.example.com:8443 needs that exact port in the entry.
  • Wildcard scope. https://*.example.com does not cover https://example.com itself. List the apex domain separately.
  • Admin on a different domain. If the admin lives on admin.example.com and posts only to itself, no entry is needed for it.

Next steps

FAQ

Why did this start after upgrading Django?

Django 4.0 changed the setting from host names to origins and added the 4_0.E001 check. Values that worked on 3.2 are rejected as soon as you upgrade.

Do I need CSRF_TRUSTED_ORIGINS at all?

Only for unsafe requests that come from a different origin than the site handling them, such as a frontend on another subdomain or a site reached through a different domain. Same-origin forms do not need it.

Can I use http:// in production?

Only for origins that really are served over plain HTTP, such as an internal tool. Public sites should list https:// origins and redirect HTTP to HTTPS.

Is "*" allowed?

No. A bare wildcard is not a valid origin. Use a subdomain wildcard such as https://*.example.com and list the apex domain separately.

Keep going