Troubleshooting
APIs & frontends

Fix CORS Errors with a Django API in Production

Fix browser CORS errors calling a Django API: django-cors-headers setup, middleware order, preflight requests, credentials, and errors without CORS headers.

Problem statement

A frontend on https://app.example.com calls a Django API on https://api.example.com. It worked in development through a dev server proxy, but in production the browser console shows:

Access to fetch at 'https://api.example.com/api/orders/' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.

or:

Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'
when the request's credentials mode is 'include'.

CORS (Cross-Origin Resource Sharing) is enforced by browsers. The server receives the request (or the preflight), and the browser decides whether JavaScript may read the response based on response headers. That is why the same API call works in curl or Postman and fails in the browser.

A CORS error message is also frequently a disguise: when the API returns 500, 502, or a redirect without CORS headers, the browser reports a CORS failure instead of the real error.

A cross-origin API callBrowserorigin APreflightOPTIONS requestDjangoCorsMiddlewareResponseallow headersThe browser enforces CORS; the server only decides what headers to send.

Quick answer

  1. Open the Network tab and look at the actual response status of the request and of any OPTIONS preflight.
  2. Install django-cors-headers, add corsheaders to INSTALLED_APPS, and put CorsMiddleware above CommonMiddleware and any middleware that can return responses.
  3. Set CORS_ALLOWED_ORIGINS to exact origins, including scheme and port, with no trailing slash.
  4. For cookies or session auth, set CORS_ALLOW_CREDENTIALS = True, use credentials: "include" in fetch, and add the frontend origin to CSRF_TRUSTED_ORIGINS.
  5. Allow custom request headers with CORS_ALLOW_HEADERS if the client sends them.
  6. Make sure errors from Nginx or redirects also reach the browser with CORS headers, or fix the underlying error.
  7. Verify with curl preflight requests using the frontend's Origin.

Step-by-step solution

1. Find the real response

In the browser's Network tab, click the failed request:

  • Is there an OPTIONS request before it? That is the preflight. Browsers send one for non-simple requests: methods other than GET, HEAD, or POST, JSON content types, or custom headers such as Authorization.
  • What status did the preflight return? It must be a 2xx status with the right headers.
  • What status did the main request return? A 401, 404, 500, or 301 without CORS headers shows up in the console as a CORS error.
the CORS error is often a symptomWhat the Network tab showsReal problemPreflight gets 301 or 302redirect: HTTP, or a missing slashResponse is 500 without CORS headersserver error; fix that firstOrigin not in allow listCORS_ALLOWED_ORIGINS mismatchCredentials with *wildcard not allowed with cookiesRead the actual response, not only the console messagedjango-deployment.com
curl with an Origin header reproduces the exchange outside the browser.

Reproduce outside the browser:

curl -si -X OPTIONS https://api.example.com/api/orders/ \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization" | head -20

curl -si https://api.example.com/api/orders/ \
  -H "Origin: https://app.example.com" | head -20

2. Install and order the middleware

pip install django-cors-headers
CorsMiddleware goes highSecurityMiddlewarefirstCorsMiddlewarebefore anything that can respondCommonMiddlewarecan redirect or respond earlyCsrfViewMiddleware, auth…the restMiddleware above it can answer without CORS headersdjango-deployment.com
Placed below CommonMiddleware, slash redirects go out without CORS headers.
INSTALLED_APPS = [
    # ...
    "corsheaders",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]

CorsMiddleware must run before middleware that can generate a response without calling the rest of the stack. CommonMiddleware's APPEND_SLASH redirect is the classic example: a redirect response generated before CORS headers are added fails in the browser. Placing it after WhiteNoise is fine if static files do not need CORS; place it before if they do.

3. Allow exact origins

CORS_ALLOWED_ORIGINS = [
    "https://app.example.com",
    "https://admin.example.com",
]

An origin is scheme, host, and port. These do not match https://app.example.com:

  • http://app.example.com (different scheme)
  • https://app.example.com/ (trailing slash; django-cors-headers rejects this at startup with a system check error)
  • https://www.app.example.com
  • https://app.example.com:8443

For preview deployments with changing subdomains, use a strict regex rather than allowing all origins:

CORS_ALLOWED_ORIGIN_REGEXES = [
    r"^https://[a-z0-9-]+\.preview\.example\.com$",
]

Limit CORS to API paths if other URLs should not be readable cross-origin:

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

4. Credentials: cookies and sessions

If the frontend sends cookies (Django session authentication):

CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
cookies across originsRejected by browsers✕Access-Control-Allow-Origin: *✕with credentials: include✕cookie SameSite=LaxWorks✓exact origin echoed back✓CORS_ALLOW_CREDENTIALS = True✓SameSite=None; Secure where neededCross-site cookies also need CSRF_TRUSTED_ORIGINS for unsafe methodsdjango-deployment.com
Same-site subdomains can keep SameSite=Lax; only truly cross-site needs None.
await fetch("https://api.example.com/api/orders/", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-CSRFToken": csrfToken,
  },
  body: JSON.stringify(payload),
});

Rules that cause errors when broken:

  • with credentials, Access-Control-Allow-Origin must be the exact origin, never *; django-cors-headers handles this when origins are configured explicitly
  • CORS_ALLOW_ALL_ORIGINS = True with CORS_ALLOW_CREDENTIALS = True would let any website make authenticated requests on behalf of your users, so never combine them
  • SameSite=Lax cookies are sent between app.example.com and api.example.com because they are the same site (same registrable domain). A frontend on a completely different domain needs SameSite=None; Secure cookies, and browsers increasingly restrict third-party cookies, so token authentication is often a better fit there.

If the frontend reads the CSRF token from a cookie, set CSRF_COOKIE_DOMAIN = ".example.com" so JavaScript on app.example.com can read a cookie set by api.example.com, or expose the token through an API endpoint instead.

5. Token authentication and custom headers

With Authorization: Bearer ... headers and no cookies, credentials mode is not needed, but the preflight must allow the header. django-cors-headers allows authorization and content-type by default among others. Add custom ones:

from corsheaders.defaults import default_headers

CORS_ALLOW_HEADERS = (*default_headers, "x-request-id")

If JavaScript needs to read response headers such as Content-Disposition or pagination headers, expose them:

CORS_EXPOSE_HEADERS = ["Content-Disposition", "X-Total-Count"]

6. Errors without CORS headers

When Django returns an error, CorsMiddleware still adds headers as long as the response passes back through it. But responses generated outside Django have no CORS headers:

  • 502 Bad Gateway or 504 from Nginx
  • 413 Request Entity Too Large from Nginx
  • a load balancer or WAF block page
  • platform error pages
a 500 dressed up as CORSPreflight OK200 with CORS headersPOST sentreal requestView raisesNginx or Django 500No CORS headerson the error pageConsole: CORS errorthe 500 is hiddenCheck the server logs whenever a CORS error appears suddenlydjango-deployment.com
Adding CORS headers to error responses in Nginx makes the real status visible.

The browser reports these as CORS errors. Fix the underlying failure first. For better client-side error handling, add CORS headers to proxy error responses in Nginx with always:

location /api/ {
    proxy_pass http://unix:/run/myapi/gunicorn.sock;
    proxy_intercept_errors off;
}

error_page 502 503 504 = @api_unavailable;

location @api_unavailable {
    default_type application/json;
    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    return 503 '{"detail": "Service temporarily unavailable"}';
}

proxy_intercept_errors off keeps Django's own error responses (which already carry CORS headers) untouched; the error_page only applies when Nginx itself cannot get a response from the upstream.

Hardcode only a single trusted origin here, or use a map of allowed origins; never echo the request's Origin header unconditionally.

7. Duplicate headers from Nginx and Django

If both Nginx and Django add Access-Control-Allow-Origin, the response contains two values, and browsers reject it with a message about multiple values. Choose one layer, normally Django, and remove add_header Access-Control-* lines from the Nginx locations that proxy to Django.

8. Redirects break preflights

Browsers do not follow redirects for preflight requests. Common causes:

  • calling /api/orders when the URL pattern is /api/orders/, so APPEND_SLASH redirects
  • calling http:// when the server redirects to https://

Use the exact final URL in the frontend configuration.

9. Verify

# Preflight from the allowed origin
curl -si -X OPTIONS https://api.example.com/api/orders/ \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,x-csrftoken" \
  | grep -i -E "^HTTP|access-control"

# Disallowed origin gets no allow header
curl -si https://api.example.com/api/orders/ -H "Origin: https://evil.example" \
  | grep -i access-control-allow-origin || echo "no CORS header for disallowed origin"

Verification check:

  • the preflight returns 200 with Access-Control-Allow-Origin: https://app.example.com, the allowed methods, and headers
  • with credentials, Access-Control-Allow-Credentials: true is present
  • disallowed origins get no Access-Control-Allow-Origin header
  • the browser console shows no CORS errors for normal flows

Explanation

The same-origin policy stops scripts on one origin from reading responses from another. CORS headers are the server's explicit exception list. django-cors-headers generates those headers from settings and answers preflight requests, but it can only do so for responses that pass through Django's middleware stack in the right order. Everything else, including proxy errors, redirects before the middleware, and duplicate headers, produces the same generic browser error, which is why reading the actual status codes is the first step.

Edge cases / notes

Same domain avoids CORS entirely

Serving the frontend and API on the same origin (for example /api/ on the same host) removes CORS from the picture.

CORS is not authentication

CORS does not protect an API from non-browser clients. Authentication, authorization, and throttling still apply to every request.

Static files and fonts

Fonts and module scripts loaded from another origin also need CORS headers, usually configured at the CDN or web server rather than in Django.

Testing CORS configuration in Django tests

CORS settings are easy to break during refactors. A couple of tests make the intended policy explicit:

from django.test import TestCase


class CorsPolicyTests(TestCase):
    def test_allowed_origin_gets_header(self):
        response = self.client.options(
            "/api/orders/",
            HTTP_ORIGIN="https://app.example.com",
            HTTP_ACCESS_CONTROL_REQUEST_METHOD="POST",
        )
        self.assertEqual(response.headers.get("access-control-allow-origin"), "https://app.example.com")
        self.assertEqual(response.headers.get("access-control-allow-credentials"), "true")

    def test_unknown_origin_gets_no_header(self):
        response = self.client.get("/api/orders/", HTTP_ORIGIN="https://evil.example")
        self.assertNotIn("access-control-allow-origin", response.headers)

The test client does not enforce CORS the way a browser does, but it verifies the headers your middleware produces, which is what the browser evaluates.

Preflight caching

Browsers cache successful preflight responses for the time given in Access-Control-Max-Age. django-cors-headers sets it from CORS_PREFLIGHT_MAX_AGE (86400 seconds by default). Browsers cap the value (Chromium-based browsers at two hours), so a preflight still happens periodically. While debugging, disable the browser cache in developer tools, otherwise a cached preflight can make a configuration change appear not to work, or a fixed problem appear to persist.

Environment-specific origins

Keep allowed origins in environment variables so staging and production differ without code changes:

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

Staging then allows https://app.staging.example.com and production allows https://app.example.com, and a staging frontend can never make credentialed calls to the production API.

Common mistakes

  • Using CORS_ALLOW_ALL_ORIGINS = True in production to silence errors.
  • Adding the origin with a trailing slash or the wrong scheme.
  • Placing CorsMiddleware below CommonMiddleware.
  • Adding CORS headers in both Nginx and Django.

Next steps

FAQ

Why does my Django API work in Postman but fail with CORS in the browser?

CORS is enforced by browsers, not servers. Postman and curl ignore it. The browser blocks JavaScript from reading responses that lack the right Access-Control-Allow-* headers.

Where should CorsMiddleware go in Django's middleware list?

As high as possible, and above CommonMiddleware or any other middleware that can return a response early, such as redirects.

Can I use CORS_ALLOW_ALL_ORIGINS with credentials?

No. That would allow any website to make authenticated requests with your users' cookies. List exact trusted origins instead.

Why does a 500 error show up as a CORS error?

If the error response is generated outside Django, or before the CORS middleware runs, it lacks CORS headers. The browser then reports a CORS failure instead of exposing the status.

Keep going