CORS Handling in Python — Deep Dive

The preflight handshake in detail

When a browser sends a cross-origin request with a JSON body to a Python API, this is what actually happens on the wire:

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The server responds:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Only after this handshake succeeds does the browser send the actual POST. The Max-Age of 86400 (24 hours) means the browser won’t repeat this preflight for the same origin/method/headers combination for a day.

FastAPI CORS middleware internals

FastAPI’s CORSMiddleware (from Starlette) processes requests in this order:

  1. If the request has no Origin header, skip CORS processing entirely (it’s a same-origin or non-browser request).
  2. If it’s an OPTIONS request with Access-Control-Request-Method, treat it as a preflight: check the origin, return appropriate headers, and respond with 200.
  3. For actual requests, check the origin and add CORS headers to the response.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com", "https://staging.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
    expose_headers=["X-Request-ID", "X-RateLimit-Remaining"],
    max_age=3600,
)

expose_headers is often overlooked. By default, browsers only expose “simple” response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma) to JavaScript. Custom headers like X-Request-ID need explicit exposure.

Dynamic origin validation

Hardcoded origin lists break when you have per-tenant subdomains (customer1.app.com, customer2.app.com) or dynamic preview deployments. For these cases, validate origins dynamically:

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import re

ALLOWED_PATTERN = re.compile(r"^https://[\w-]+\.app\.example\.com$")
STATIC_ORIGINS = {"https://app.example.com", "https://admin.example.com"}

class DynamicCORSMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        origin = request.headers.get("origin")

        if not origin:
            return await call_next(request)

        is_allowed = origin in STATIC_ORIGINS or bool(ALLOWED_PATTERN.match(origin))

        if request.method == "OPTIONS":
            if is_allowed:
                return Response(
                    status_code=204,
                    headers={
                        "Access-Control-Allow-Origin": origin,
                        "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
                        "Access-Control-Allow-Headers": "Authorization, Content-Type",
                        "Access-Control-Allow-Credentials": "true",
                        "Access-Control-Max-Age": "3600",
                        "Vary": "Origin",
                    },
                )
            return Response(status_code=403)

        response = await call_next(request)

        if is_allowed:
            response.headers["Access-Control-Allow-Origin"] = origin
            response.headers["Access-Control-Allow-Credentials"] = "true"
            response.headers["Vary"] = "Origin"

        return response

The Vary: Origin header is critical. Without it, CDNs and proxies might cache a response with one origin’s CORS headers and serve it to a request from a different origin — breaking CORS for the second origin.

Django CORS configuration

Django uses django-cors-headers:

# settings.py
INSTALLED_APPS = [
    "corsheaders",
    # ...
]

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

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

# Or for regex patterns:
CORS_ALLOWED_ORIGIN_REGEXES = [
    r"^https://[\w-]+\.preview\.example\.com$",
]

CORS_ALLOW_CREDENTIALS = True
CORS_PREFLIGHT_MAX_AGE = 86400
CORS_ALLOW_HEADERS = list(default_headers) + ["X-Custom-Header"]

Middleware ordering matters. CorsMiddleware must come before CommonMiddleware (which handles things like URL normalization). If CommonMiddleware redirects a URL before CORS headers are added, the redirect response won’t have CORS headers, and the browser blocks it.

Flask CORS setup

from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app, resources={
    r"/api/*": {
        "origins": ["https://app.example.com"],
        "methods": ["GET", "POST", "PUT", "DELETE"],
        "allow_headers": ["Authorization", "Content-Type"],
        "supports_credentials": True,
        "max_age": 3600,
    }
})

Flask-CORS supports per-route configuration via resources, so you can apply strict CORS to API routes while leaving static file routes unrestricted.

Debugging CORS failures

CORS errors are exclusively browser-side. The server might return a valid response, but if the CORS headers are wrong, the browser throws an error and JavaScript gets nothing.

Step 1: Check the browser console. The error message tells you what’s wrong: missing Allow-Origin, mismatched origin, credentials with wildcard, disallowed header.

Step 2: Inspect the actual HTTP exchange. Open Network tab, find the request (and the OPTIONS preflight if applicable). Check response headers. Is Access-Control-Allow-Origin present? Does it match the requesting origin exactly (scheme + host + port)?

Step 3: Common failures:

  • Port mismatch. http://localhost:3000 and http://localhost:8000 are different origins. Your frontend dev server origin must be in the allow list.
  • HTTP vs HTTPS. http://example.com and https://example.com are different origins.
  • Trailing slash. Origins don’t have trailing slashes. https://example.com/ is not a valid origin.
  • Redirect before CORS. If your server redirects /api/users to /api/users/ (common with Django’s APPEND_SLASH), the redirect response lacks CORS headers, and the browser blocks before following the redirect.

Preflight caching and performance

Every unique combination of origin + method + custom headers triggers a preflight. For SPAs making many different API calls, this can double request counts.

Set max_age to a high value (86400 seconds = 24 hours) for production. Browsers cap this differently: Chrome at 7200 seconds (2 hours), Firefox at 86400. The server can send a higher value, but browsers enforce their own maximum.

For APIs behind CDNs (CloudFront, Cloudflare), configure the CDN to cache OPTIONS responses. Include Origin and Access-Control-Request-Headers in the CDN’s cache key so different origins don’t get each other’s cached preflight responses.

Security considerations

Never use allow_origins=["*"] with credentials. Browsers reject it, but even without credentials, a wildcard allows any website to read your API responses. If your API returns user-specific or sensitive data, use explicit origins.

Be careful with regex patterns. A pattern like r".*\.example\.com" also matches evil-example.com. Anchor patterns properly: r"^https://[\w-]+\.example\.com$".

Subdomains are separate origins. api.example.com cannot access example.com cookies without proper Domain cookie settings, and CORS for example.com doesn’t automatically cover api.example.com.

CORS does not replace authentication. A misconfigured CORS policy might leak data, but even a perfect CORS setup doesn’t protect against direct API attacks (from curl, scripts, or non-browser clients). Always authenticate and authorize requests server-side.

The one thing to remember: CORS configuration in production Python apps needs explicit origin lists (not wildcards), Vary: Origin headers for caching correctness, careful middleware ordering, and awareness that CORS is a browser protection — not a substitute for server-side authentication.

pythonwebsecurityapis

See Also

  • Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
  • Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
  • Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
  • Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
  • Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.