CORS in Django: setting up django-cors-headers
Django has no built-in equivalent of Express's cors middleware — a plain view answers every Origin the same way, which to a browser looks like no CORS headers at all. django-cors-headers is the standard fix, and it's simple to install, but one line in the wrong place breaks it in a way that only shows up on redirects. We built a throwaway Django 5.2 project and tested each claim below against django-cors-headers 4.9.0 rather than quoting the README from memory.
What Django does with nothing installed
A plain function-based view doesn't special-case OPTIONS at all — it just runs like any other request. We pointed a bare Django 5.2 project with no CORS app at a JSON view and sent a preflight:
curl -i -X OPTIONS http://127.0.0.1:8000/api/widgets/ \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: GET"
It came back 200 OK with the view's actual JSON body — no Allow header, no Access-Control-Allow-Origin, nothing CORS-specific. The request technically "succeeds" on the wire, which is exactly why this is confusing to debug: curl shows a clean 200, but the browser reads the same response, finds no Access-Control-Allow-Origin header, and blocks the real request anyway. (A class-based django.views.generic.base.View behaves slightly differently — it has a default options() that returns an Allow header — but neither path emits a single CORS header without help.)
Installing django-cors-headers
The package is maintained at github.com/adamchainz/django-cors-headers; the current release is 4.9.0, supporting Django 4.2 through 6.0 and Python 3.9 through 3.14, per its PyPI listing.
pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
# ...
"corsheaders",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# ... the rest of your middleware
]
CORS_ALLOWED_ORIGINS = [
"https://app.example.com",
]
The README is specific about where CorsMiddleware goes: as high in the list as you can put it, and before CommonMiddleware in particular. We installed exactly the config above and re-ran the preflight: this time the OPTIONS response carried access-control-allow-origin: https://app.example.com, access-control-allow-methods, access-control-allow-headers, and access-control-max-age: 86400 — that default max-age is a day, well past every browser's own preflight-cache ceiling, so it isn't the limiting factor. A follow-up GET from the same origin came back with Access-Control-Allow-Origin and Vary: origin set; the identical request from a different origin got neither header, which is the allowlist doing its job.
The ordering mistake that only shows up on a redirect
The warning about middleware placement isn't academic. We moved CorsMiddleware to run after CommonMiddleware instead of before it, then requested a URL missing its trailing slash — the case Django's APPEND_SLASH handles by issuing a redirect before the view ever runs:
curl -i http://127.0.0.1:8000/api/widgets \
-H "Origin: https://app.example.com"
With CorsMiddleware correctly placed first, the 301 redirect response still carried access-control-allow-origin. With it moved after CommonMiddleware, the exact same request came back 301 with no CORS headers at all — CommonMiddleware returns the redirect before the request chain ever reaches CorsMiddleware, so there's nothing left to add the header to on the way back. A frontend hitting a URL that's missing a trailing slash would see this as CORS working for every endpoint except one, which is a slow bug to track down unless you know to check middleware order first.
Choosing an origin setting
django-cors-headers gives you three ways to decide who gets a response, and they aren't interchangeable:
CORS_ALLOWED_ORIGINS— an explicit list of origins, each echoed back asAccess-Control-Allow-Originwhen it matches. The right default for anything with a known, finite set of frontends.CORS_ALLOWED_ORIGIN_REGEXES— for a pattern, such as every subdomain of one app. The README calls this out specifically for cases where listing every origin inCORS_ALLOWED_ORIGINSwould be impractical.CORS_ALLOW_ALL_ORIGINS— defaults toFalse; setting itTrueallows any origin. Useful for a genuinely public, unauthenticated API; the README itself warns this "can be dangerous" since it allows any website to make cross-origin requests to yours. It also can't be combined usefully with credentials — the Fetch spec rejects a wildcardAccess-Control-Allow-Originalongsidecredentials: 'include'on the client, the same rule that bites Next.js and every other framework.
All three respect CORS_URLS_REGEX (default r'^.*$', i.e. every URL), which is the setting to narrow if you only want CORS applied under /api/ and nothing else on the same Django install.
Cookies, credentials, and CSRF_TRUSTED_ORIGINS
If the frontend needs to send a session cookie across origins, CORS_ALLOW_CREDENTIALS = True is required on top of the allowlist. We added it and re-sent a request with a cookie attached:
CORS_ALLOW_CREDENTIALS = True
The response gained access-control-allow-credentials: true and Vary: origin, Cookie. That's necessary but not sufficient — the same four moving parts that have to align for any cross-origin cookie still apply, and Django's own SESSION_COOKIE_SAMESITE defaults to 'Lax', which still blocks the session cookie from attaching on a genuinely cross-site request even once CORS allows it. Separately, CORS and CSRF are unrelated checks in Django: allowing an origin in CORS_ALLOWED_ORIGINS does nothing for Django's CSRF Referer check on an unsafe request, so a credentialed POST from your frontend's origin also needs that origin in CSRF_TRUSTED_ORIGINS. Since Django 4.0, every entry there needs a scheme — we set it to a bare hostname to confirm, and manage.py check refused 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
app.example.com. See the release notes for details.
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"] is what actually starts the server.
When this isn't the fix
Everything above controls headers your own Django server sends. It does nothing for the opposite direction — calling a third-party API that doesn't send CORS headers back, which CorsMiddleware running on your server can't change, because you don't control that response. For that case, the options are the same ones covered in why am I getting a CORS error: a server-side call from your Django view (same-origin as far as the browser's concerned), or a CORS proxy in front of the third-party API if you need the browser to call it directly.