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.
Quick answer
- Open the Network tab and look at the actual response status of the request and of any
OPTIONSpreflight. - Install
django-cors-headers, addcorsheaderstoINSTALLED_APPS, and putCorsMiddlewareaboveCommonMiddlewareand any middleware that can return responses. - Set
CORS_ALLOWED_ORIGINSto exact origins, including scheme and port, with no trailing slash. - For cookies or session auth, set
CORS_ALLOW_CREDENTIALS = True, usecredentials: "include"in fetch, and add the frontend origin toCSRF_TRUSTED_ORIGINS. - Allow custom request headers with
CORS_ALLOW_HEADERSif the client sends them. - Make sure errors from Nginx or redirects also reach the browser with CORS headers, or fix the underlying error.
- Verify with
curlpreflight requests using the frontend'sOrigin.
Step-by-step solution
1. Find the real response
In the browser's Network tab, click the failed request:
- Is there an
OPTIONSrequest before it? That is the preflight. Browsers send one for non-simple requests: methods other thanGET,HEAD, orPOST, JSON content types, or custom headers such asAuthorization. - What status did the preflight return? It must be a
2xxstatus with the right headers. - What status did the main request return? A
401,404,500, or301without CORS headers shows up in the console as a CORS error.
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
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.comhttps://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"
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-Originmust be the exact origin, never*; django-cors-headers handles this when origins are configured explicitly CORS_ALLOW_ALL_ORIGINS = TruewithCORS_ALLOW_CREDENTIALS = Truewould let any website make authenticated requests on behalf of your users, so never combine themSameSite=Laxcookies are sent betweenapp.example.comandapi.example.combecause they are the same site (same registrable domain). A frontend on a completely different domain needsSameSite=None; Securecookies, 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 Gatewayor504from Nginx413 Request Entity Too Largefrom Nginx- a load balancer or WAF block page
- platform error pages
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/orderswhen the URL pattern is/api/orders/, soAPPEND_SLASHredirects - calling
http://when the server redirects tohttps://
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
200withAccess-Control-Allow-Origin: https://app.example.com, the allowed methods, and headers - with credentials,
Access-Control-Allow-Credentials: trueis present - disallowed origins get no
Access-Control-Allow-Originheader - 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 = Truein production to silence errors. - Adding the origin with a trailing slash or the wrong scheme.
- Placing
CorsMiddlewarebelowCommonMiddleware. - Adding CORS headers in both Nginx and Django.
Next steps
- Harden the API with Deploy a Django REST Framework API to Production.
- Avoid cross-origin setups with Serve Django and a React SPA on the Same Domain.
- Fix
403responses on writes with CSRF Verification Failed in Django Production.
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.