Configuring django-cors-headers Correctly

A Django API at https://api.northwind.dev serving a front end at https://portal.northwind.dev fails its preflight with this in the browser console:

Access to fetch at 'https://api.northwind.dev/v1/invoices' from origin
'https://portal.northwind.dev' has been blocked by CORS policy: Response
to preflight request doesn't pass access control check: Redirect is not
allowed for a preflight request.

Some projects see a different sentence for the same underlying fault — No 'Access-Control-Allow-Origin' header is present on the requested resource — depending on which middleware got to the request first. In both cases django-cors-headers is installed, CORS_ALLOWED_ORIGINS names the right origin, and the settings file looks correct.

Root cause

CorsMiddleware short-circuits a preflight only if it is reached. Django walks MIDDLEWARE top to bottom on the request path, and several stock entries can produce a complete response during that walk. CommonMiddleware is the usual culprit: with APPEND_SLASH enabled (the default) it notices that /v1/invoices does not resolve, discovers that /v1/invoices/ does, and returns a 301 immediately. That redirect is generated before CorsMiddleware ever executes, so it carries no Access-Control-* headers — and the Fetch Standard forbids following a redirect during a CORS-preflight fetch, so the browser stops there. Move CorsMiddleware above it and the preflight is answered before CommonMiddleware gets an opinion. This page is the Django walkthrough for Framework CORS Middleware Configuration, which covers the same ordering rule across other stacks.

Prerequisite state

Step 1 — Install the app and put the middleware first

Add corsheaders to INSTALLED_APPS, then insert the middleware at index 0. The position, not the presence, is what fixes the redirect failure.

# settings.py
INSTALLED_APPS = [
    "corsheaders",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "invoices",
]

MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",          # index 0 — must be first
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
]

Every entry below index 0 is capable of ending a preflight on its own terms, and none of them knows anything about CORS.

What each MIDDLEWARE entry would do to an unanswered preflight A six-row listing of the MIDDLEWARE setting in request order. CorsMiddleware sits at index zero and answers the preflight; the five entries below it are annotated with the response each would return if it reached the request first. MIDDLEWARE, in request order What it would do to the preflight 0 corsheaders.middleware.CorsMiddleware answers it with the header set 1 django.middleware.security.SecurityMiddleware 301 to https:// on a plain request 2 whitenoise.middleware.WhiteNoiseMiddleware serves or rejects a static path 3 django.middleware.common.CommonMiddleware 301 for a missing trailing slash 4 django.contrib.sessions.middleware.SessionMiddleware loads a session that is not there 5 django.middleware.csrf.CsrfViewMiddleware 403 on a token-free write Index 0 is not a style preference — every entry below it can end the exchange first

The redirect failure in particular is worth seeing as a sequence, because the 301 looks innocuous in a server log and the browser never sends the request that would have followed it.

The APPEND_SLASH redirect trap, before and after reordering The upper sequence shows a preflight reaching CommonMiddleware first, which returns a 301 to the slashed path with no CORS headers, and the browser refuses to follow it. The lower sequence shows the same request reaching CorsMiddleware first, which answers with a 200 and the full Access-Control header set. CorsMiddleware below CommonMiddleware OPTIONS /v1/invoices no trailing slash CommonMiddleware runs APPEND_SLASH resolves it 301 to /v1/invoices/ bare, and never followed CorsMiddleware at index 0 OPTIONS /v1/invoices the identical request CorsMiddleware runs recognises the preflight 200 with the header set CommonMiddleware skipped A preflight is never redirected — the browser treats a 3xx here as a hard failure

Step 2 — Declare the allowlist

CORS_ALLOWED_ORIGINS takes full origins: scheme, host, optional port, and no path or trailing slash. Django’s system check framework rejects malformed entries at start-up rather than at request time, so a typo here surfaces as a start-up error instead of a mysterious browser failure.

CORS_ALLOWED_ORIGINS = [
    "https://portal.northwind.dev",
    "https://admin.northwind.dev",
]

# Wrong, and each of these fails a start-up check or silently never matches:
#   "portal.northwind.dev"          -> no scheme
#   "https://portal.northwind.dev/" -> trailing slash
#   "https://*.northwind.dev"       -> not a pattern field

Wildcard subdomains belong in CORS_ALLOWED_ORIGIN_REGEXES, and the pattern must be anchored at both ends or it will match a lookalike host that merely contains your domain.

CORS_ALLOWED_ORIGIN_REGEXES = [
    r"^https://[a-z0-9-]{1,63}\.northwind\.dev$",
]

The three allowlist settings behave differently enough that it is worth being explicit about what each one puts on the wire.

Allowlist setting, emitted header, and verdict A three-way decision tree. CORS_ALLOW_ALL_ORIGINS emits a wildcard and breaks credentials, CORS_ALLOWED_ORIGINS echoes the matched origin with Vary and is the safe default, and CORS_ALLOWED_ORIGIN_REGEXES echoes on a pattern match and is safe only when the pattern is anchored. How is the allowlist expressed? CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOWED_ORIGINS = ["https://portal..."] CORS_ALLOWED_ORIGIN_REGEXES = [r"^https://..."] emits a literal asterisk and no Vary header echoes the matched origin and adds Vary: Origin echoes on a pattern hit and adds Vary: Origin credentials stop working the safe default safe only when anchored The two allowlist fields can be combined; the wildcard switch overrides both

Step 3 — Turn on credentials, and remember CSRF

If the front end calls fetch(url, { credentials: "include" }), Django needs two separate permissions. CORS_ALLOW_CREDENTIALS lets the browser expose the response; CSRF_TRUSTED_ORIGINS lets CsrfViewMiddleware accept the write. They are enforced by different components and neither implies the other, a distinction explored further in Credential Sharing & Security Boundaries in CORS.

CORS_ALLOW_CREDENTIALS = True

# Django 4+ requires the scheme on every entry.
CSRF_TRUSTED_ORIGINS = ["https://portal.northwind.dev"]

# Cross-site cookies must be explicitly marked, and SameSite=None implies Secure.
SESSION_COOKIE_SAMESITE = "None"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SECURE = True

Step 4 — Scope the policy and set the rest of the header set

CORS_URLS_REGEX keeps the policy off the admin site and any internal route, which matters because the settings below otherwise apply to every URL Django serves.

CORS_URLS_REGEX = r"^/v1/.*$"

CORS_ALLOW_METHODS = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]
CORS_ALLOW_HEADERS = ["authorization", "content-type", "x-request-id"]
CORS_EXPOSE_HEADERS = ["x-request-id", "x-total-count"]
CORS_PREFLIGHT_MAX_AGE = 600

CORS_EXPOSE_HEADERS is the one people forget. Without it the request succeeds, the response arrives, and response.headers.get("X-Total-Count") returns null with nothing in the console to explain why. CORS_PREFLIGHT_MAX_AGE defaults to 86400 seconds, which browsers clamp downward anyway — the reasoning behind picking a value is in How to Set Access-Control-Max-Age Effectively.

Verification

Reproduce the preflight from the shell first, because curl shows you the status line the browser hides behind a CORS message:

curl -sS -i -X OPTIONS https://api.northwind.dev/v1/invoices/ \
  -H 'Origin: https://portal.northwind.dev' \
  -H 'Access-Control-Request-Method: PATCH' \
  -H 'Access-Control-Request-Headers: authorization, content-type'

A correct configuration returns HTTP/1.1 200 OK with access-control-allow-origin: https://portal.northwind.dev, access-control-allow-methods, access-control-allow-headers, access-control-allow-credentials: true, access-control-max-age: 600 and vary: origin. A 301 in the first line means the ordering fix has not taken effect.

Security boundary note

CORS_ALLOW_ALL_ORIGINS = True is not a debugging aid, it is a production decision that you will forget you made. It emits Access-Control-Allow-Origin: *, which the browser refuses to combine with credentials, so the usual next step is to “fix” that by removing credentials: "include" from the front end — quietly converting a session-authenticated API into one where the wildcard is now the whole access-control story. If a development machine genuinely needs http://localhost:5173, put that entry in a settings module that only the development environment imports, following the pattern in Safely Allowing localhost Origins in Development. And keep CORS_URLS_REGEX tight: without it the policy also covers /admin/ and any internal endpoint the same Django process serves.

Common Mistakes

Mistake Technical impact Fix
CorsMiddleware placed below CommonMiddleware APPEND_SLASH returns a bare 301 and the browser refuses to follow a redirect during a preflight Move the entry to index 0 of MIDDLEWARE
Trailing slash or missing scheme in CORS_ALLOWED_ORIGINS The entry never matches the browser’s Origin string, so the header is silently omitted Use https://host exactly, with no path; run manage.py check
Using the removed CORS_ORIGIN_WHITELIST name The setting is ignored by version 4, so the allowlist is effectively empty Rename to CORS_ALLOWED_ORIGINS (and CORS_ORIGIN_ALLOW_ALL to CORS_ALLOW_ALL_ORIGINS)
Enabling credentials but omitting CSRF_TRUSTED_ORIGINS The preflight passes and the POST still fails with a 403 CSRF error Add the front-end origin, with scheme, to CSRF_TRUSTED_ORIGINS

FAQ

Where exactly should CorsMiddleware go in the MIDDLEWARE list?

At index 0 in almost every project. Django runs MIDDLEWARE top to bottom on the request path, and CorsMiddleware has to see the preflight before anything that can generate a response of its own. The two entries it must beat are CommonMiddleware, which issues APPEND_SLASH and PREPEND_WWW redirects during the request phase, and WhiteNoiseMiddleware, which serves or rejects static paths. Putting it above SecurityMiddleware as well costs nothing and removes the last ordering question.

Why does my preflight return a 301 instead of a 200?

Because APPEND_SLASH is on, your request path has no trailing slash, and CommonMiddleware is above CorsMiddleware. CommonMiddleware resolves the slashed variant of the path during the request phase and returns a redirect immediately, so CorsMiddleware never runs and the redirect carries no Access-Control headers. Browsers refuse to follow a redirect during a preflight, so the whole exchange fails. Move CorsMiddleware to index 0, and make the front end call the path the router actually declares.

Do I still need CSRF_TRUSTED_ORIGINS when django-cors-headers is working?

Yes, for any cookie-authenticated POST, PUT, PATCH or DELETE. CORS and CSRF are separate checks enforced by different components: django-cors-headers decides whether the browser may read the response, while CsrfViewMiddleware decides whether Django will accept the write at all. On Django 4 and later every entry in CSRF_TRUSTED_ORIGINS must include the scheme, and the front-end origin belongs in that list as well as in CORS_ALLOWED_ORIGINS.