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.
- Prefix every entry with its scheme:
CSRF_TRUSTED_ORIGINS = ["https://example.com"]. - For subdomains use
"https://*.example.com", not".example.com". - Include a port only when it is non-standard:
"http://localhost:8000". - Never add a path or trailing slash:
"https://example.com/"does not match. - 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.
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.
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:8443needs that exact port in the entry. - Wildcard scope.
https://*.example.comdoes not coverhttps://example.comitself. List the apex domain separately. - Admin on a different domain. If the admin lives on
admin.example.comand posts only to itself, no entry is needed for it.
Next steps
- If the check passes but forms still fail, work through Fix CSRF Verification Failed in Django Production.
- Keep host-related settings consistent with Fix DisallowedHost in Django Production.
- Review every security setting with the Django Production Settings Checklist.
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.