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:
- If the request has no
Originheader, skip CORS processing entirely (it’s a same-origin or non-browser request). - 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. - 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:3000andhttp://localhost:8000are different origins. Your frontend dev server origin must be in the allow list. - HTTP vs HTTPS.
http://example.comandhttps://example.comare 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/usersto/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.
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.