CORS in Django REST Framework looks simple right up until your frontend starts throwing No 'Access-Control-Allow-Origin' header errors and every “quick fix” makes your API less safe.

I’ve seen teams handle this in three common ways:

  1. disable CORS in development and forget about production
  2. slap Access-Control-Allow-Origin: * on everything
  3. actually configure it properly with environment-specific rules

Only one of those scales without causing pain.

The short version

If you’re building a DRF API, your realistic CORS options are:

  • Use django-cors-headers — the standard choice for most projects
  • Write custom middleware — only worth it for unusual policies
  • Set CORS at the reverse proxy/CDN — great in some deployments, but easy to drift from app behavior
  • Use wildcard CORS — acceptable for some truly public, unauthenticated APIs, but usually too permissive

For most teams, I’d pick django-cors-headers plus a strict allowlist and move on.

First: what CORS is actually doing

CORS is the browser asking your API, “Can this page from another origin read your response?”

That “other origin” differs by scheme, host, or port:

  • https://app.example.com
  • https://admin.example.com
  • http://localhost:3000

Those are all different origins.

DRF itself doesn’t “solve” CORS. CORS is just HTTP response headers, and browsers enforce them. Your API may work perfectly in curl while failing in the browser.

A simple public API response might include:

Access-Control-Allow-Origin: *

GitHub’s API does exactly that for public cross-origin access:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That exposed headers list is a good reminder: CORS isn’t only about Allow-Origin. If your frontend needs to read ETag or rate limit headers, you may need Access-Control-Expose-Headers too.

Option 1: django-cors-headers

This is the default answer for DRF, and honestly, it should be.

Official docs:

Basic setup

Install it:

pip install django-cors-headers

Add it to INSTALLED_APPS:

INSTALLED_APPS = [
    "corsheaders",
    "rest_framework",
    # ...
]

Put the middleware near the top:

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

Allow your frontend origins:

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

If you use cookies or session auth across origins:

CORS_ALLOW_CREDENTIALS = True

And if your frontend needs custom readable headers:

CORS_EXPOSE_HEADERS = [
    "ETag",
    "Link",
    "X-RateLimit-Limit",
    "X-RateLimit-Remaining",
]

Pros

  • Battle-tested. Most Django teams use it for a reason.
  • Readable config. New team members can understand it fast.
  • Handles preflight correctly. That alone saves time.
  • Supports granular rules for origins, methods, headers, regexes.
  • Works cleanly with DRF because it lives at middleware level.

Cons

  • Another dependency. Usually fine, but some orgs hate adding packages.
  • Easy to misconfigure if you cargo-cult examples like CORS_ALLOW_ALL_ORIGINS = True.
  • Can hide architecture issues. If auth/session/cookie design is messy, CORS config gets weird fast.

My take

Use it unless you have a very specific reason not to. Reinventing CORS middleware in Django is usually wasted effort.

Option 2: custom Django middleware

You can write your own middleware and attach CORS headers manually.

Example:

class SimpleCORSMiddleware:
    ALLOWED_ORIGINS = {
        "https://app.example.com",
        "http://localhost:3000",
    }

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        origin = request.headers.get("Origin")

        if request.method == "OPTIONS" and origin in self.ALLOWED_ORIGINS:
            response = HttpResponse(status=204)
        else:
            response = self.get_response(request)

        if origin in self.ALLOWED_ORIGINS:
            response["Access-Control-Allow-Origin"] = origin
            response["Vary"] = "Origin"
            response["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
            response["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
            response["Access-Control-Allow-Credentials"] = "true"

        return response

This works, but it’s the kind of code that grows sharp edges.

Pros

  • Full control over policy and behavior
  • No third-party dependency
  • Useful for unusual logic, like tenant-specific origin validation

Cons

  • You will forget edge cases
  • Preflight handling gets messy
  • Maintenance burden lands on your team
  • Harder to audit than a standard package

My take

I’d only do this if I needed dynamic origin checks tied to tenant config, and even then I’d think hard before building it myself.

If you go this route, test:

  • OPTIONS preflight
  • credentialed requests
  • custom request headers
  • caching behavior with Vary: Origin
  • error responses, not just happy paths

A lot of homegrown middleware gets the 200 response right and the 403/500 responses wrong.

Option 3: configure CORS at Nginx, Apache, or CDN

Sometimes the API sits behind a reverse proxy or edge layer that injects headers.

Example in Nginx:

location /api/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin "https://app.example.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
        add_header Access-Control-Allow-Credentials "true" always;
        return 204;
    }

    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    add_header Access-Control-Allow-Credentials "true" always;

    proxy_pass http://django_upstream;
}

Pros

  • Fast and centralized
  • Keeps app code cleaner
  • Useful when multiple backends share one edge policy

Cons

  • Policy drift between infra and app expectations
  • Harder local development
  • More annoying debugging because behavior lives outside Django
  • Dynamic origin logic is awkward

My take

This is fine for mature platform teams, especially if CORS is standardized across services. For a normal DRF app, it’s often more operational complexity than it’s worth.

I prefer CORS policy to live close to the application unless infrastructure ownership is very strong.

Option 4: wildcard CORS

This is the tempting one:

CORS_ALLOW_ALL_ORIGINS = True

Or manually:

Access-Control-Allow-Origin: *

Pros

  • Dead simple
  • Good for public read-only APIs
  • Convenient for demos and prototypes

Cons

  • Too permissive for most real apps
  • Does not work with credentials
  • Normalizes bad habits
  • Easy to forget in production

This pattern is reasonable for APIs like public metadata or open content feeds. GitHub can expose Access-Control-Allow-Origin: * because they understand their API model and browser access story.

Most internal business APIs are not GitHub.

My take

If your API uses:

  • cookies
  • session auth
  • sensitive data
  • private user-specific responses
  • admin functionality

then wildcard CORS is usually the wrong choice.

Comparison table

django-cors-headers

Best for: most DRF projects
Pros: standard, easy, reliable
Cons: extra dependency, still needs thought

Custom middleware

Best for: unusual dynamic policies
Pros: flexible, no package
Cons: easy to get wrong, higher maintenance

Proxy/CDN config

Best for: infra-led organizations
Pros: centralized, fast
Cons: harder debugging, config drift

Wildcard CORS

Best for: public unauthenticated APIs
Pros: simple
Cons: unsafe for many real apps, no credentials support

The credential trap

The biggest CORS mistake in DRF isn’t forgetting an origin. It’s mixing wildcard origins with credentialed requests.

This is invalid:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

If you need cookies or authenticated browser requests, you must return a specific origin, not *.

For example:

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

And if you’re using cookies, don’t stop at CORS. You also need to think about CSRF, cookie flags, and related headers. If you’re tightening broader browser security policy, that’s where a guide like https://csp-guide.com can help for CSP and related headers. CORS alone is not a complete browser security model.

A sane DRF setup for most teams

This is the setup I’d recommend for a typical frontend + DRF API split:

INSTALLED_APPS = [
    "corsheaders",
    "rest_framework",
    # ...
]

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

CORS_ALLOWED_ORIGINS = [
    "https://app.example.com",
    "http://localhost:3000",
]

CORS_ALLOW_CREDENTIALS = True

CORS_ALLOW_HEADERS = [
    "accept",
    "authorization",
    "content-type",
    "x-csrftoken",
]

CORS_EXPOSE_HEADERS = [
    "ETag",
    "Link",
    "X-RateLimit-Limit",
    "X-RateLimit-Remaining",
]

Then keep production and development origins separate with environment variables.

What I’d avoid

I would avoid these unless I had a very good reason:

  • CORS_ALLOW_ALL_ORIGINS = True in production
  • custom middleware for basic use cases
  • proxy-only CORS config with no app-level tests
  • copying settings without verifying preflight behavior in the browser

And I’d definitely test a real browser flow, not just Postman. Postman does not enforce CORS, so it can give you false confidence.

Final recommendation

If you want the practical answer: use django-cors-headers with a strict allowlist, expose only the headers your frontend actually needs, and enable credentials only when your auth model requires them.

Choose wildcard CORS only for deliberately public APIs.

Choose custom middleware only when your policy is truly custom.

Choose proxy-layer CORS when your platform team owns that layer well enough to debug it at 2 AM.

That’s the tradeoff. For most DRF apps, boring and explicit wins.