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 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 is a day, which equals Firefox's preflight-cache ceiling and is above Chrome's two-hour cap (see preflight caching), so in practice the browser decides how long the cache lasts. 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. Browsers reject a wildcard origin on a request made withcredentials: 'include', so don't pair it with cookies (details in CORS with cookies and credentials).
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 browser-side cookie rules in CORS with cookies and credentials still apply (the frontend must use credentials: 'include', for a start), 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 starts the server.
Calling someone else's API from a Django-backed frontend
CorsMiddleware only adds headers to responses Django sends, so it can't help when your frontend calls a third-party API that has no CORS headers. Since you already run Django, the usual answer is a small view that makes that call server-side (with requests or httpx) and returns the result; the browser then only talks to your own origin, and the allowlist and CSRF settings above cover it. A CORS proxy mainly makes sense for pages that don't go through your Django server at all. Why am I getting a CORS error? covers how to tell which side is missing the header.