2025, Dec 11 09:00

How to Prevent FastAPI CORS Failures: Correct OPTIONS Preflight and Database-Backed Origin Allowlist

Why FastAPI CORS preflight fails with dynamic origins and how to fix it: validate origin early, short-circuit OPTIONS, and set required headers in middleware.

FastAPI applications that gate CORS by origin often start with a static allowlist. As soon as you add dynamic domains from a database, subtle issues show up—most notably, browsers fail the CORS preflight and never send the actual request. Below is a clear walkthrough of what goes wrong and a working approach for handling both default and database-driven origins, including the OPTIONS preflight path.

The failing implementation

The intention is sound: return a quick reply for OPTIONS, allow static origins directly, and consult the database for others. The problem is that the preflight path doesn’t get the right CORS headers early enough, and the request pipeline is invoked when it shouldn’t be.

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.types import ASGIApp

seed_origins = [
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:8006",
]

class AdaptiveCorsLayer(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp):
super().__init__(app)

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
src = request.headers.get("origin")

# Preflight branch replies, but without ensuring headers are set for non-default origins
if request.method == "OPTIONS":
reply = JSONResponse(content={"status": "ok"})
else:
reply = await call_next(request)

# Non-browser requests
if not src:
return reply

# Static origins fast-path
if src in seed_origins:
reply.headers["Access-Control-Allow-Origin"] = src
reply.headers["Access-Control-Allow-Credentials"] = "true"
reply.headers["Access-Control-Allow-Headers"] = "*"
reply.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
reply.headers["Vary"] = "Origin"
return reply

# Extract domain, then check storage
host = src.replace("https://", "").replace("http://", "")
hit = domain_collection.find_one({"$or": [{"domain.main_domain": host}, {"domain.sub_domain": host}]})
if not hit:
raise HTTPException(status_code=403, detail=f"Domain {host} not authorized")

# Pipeline erroneously called again (including for OPTIONS), then headers are added
reply = await call_next(request)
reply.headers["Access-Control-Allow-Origin"] = src
reply.headers["Access-Control-Allow-Credentials"] = "true"
reply.headers["Access-Control-Allow-Headers"] = "*"
reply.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
reply.headers["Vary"] = "Origin"
return reply

app = FastAPI(title="API", description="API documentation for backend", version="1.0.0", docs_url=None, redoc_url=None)
app.add_middleware(AdaptiveCorsLayer)

What actually goes wrong

Browsers send a preflight OPTIONS request for cross-origin calls. If the response to that preflight lacks the correct CORS headers, the browser halts and never issues the actual request. Here the preflight path produces a JSON body but only attaches CORS headers after origin validation that happens later, and even attempts to execute the route pipeline for OPTIONS when a simple header-only reply is required. The net effect is that the browser rejects the preflight and your GET never fires.

Another subtlety is origin validation order. The decision to allow must happen before any route logic runs, because the preflight needs a definitive yes/no together with the exact CORS headers. If you defer that decision, you end up with a 200 body and missing headers or you trigger route handling for OPTIONS, which is not what the browser expects for CORS.

A working approach

The fix is to determine whether the origin is allowed first, without touching the route. If the request is OPTIONS and the origin is permitted, return immediately with the appropriate CORS headers. For non-OPTIONS requests, pass control to the route and then attach CORS headers only if the origin is allowed.

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.types import ASGIApp
from pymongo import MongoClient
import re

mongo = MongoClient("mongo_client_url")
mongo_domains = mongo.your_db.your_collection

baseline_origins = [
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:8006",
]

class FlexCorsMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp):
super().__init__(app)

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
src = request.headers.get("origin")
permitted = None

if src in baseline_origins:
permitted = src
else:
host = re.sub(r"https?://", "", src or "")
host = host.split(":")[0]
if host:
found = mongo_domains.find_one({"$or": [{"domain.main_domain": host}, {"domain.sub_domain": host}]})
if found:
permitted = src

if request.method == "OPTIONS":
if permitted:
return self._preflight_ok(permitted)
return JSONResponse(status_code=403, content={"detail": "CORS preflight failed: Unauthorized origin"})

reply = await call_next(request)

if permitted:
self._apply_cors(reply, permitted)

return reply

def _apply_cors(self, response, origin):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Vary"] = "Origin"

def _preflight_ok(self, origin):
resp = JSONResponse(content={"status": "ok"})
self._apply_cors(resp, origin)
return resp

Why this matters

The browser enforces CORS at the preflight stage. If headers don’t line up for OPTIONS, your backend will appear unreachable even though routes are healthy. Ensuring the allow/deny decision and the corresponding headers are produced before the request reaches your route logic is essential when origins come from both a static list and a database lookup.

There are also two practical nuances to keep in mind. Adding OPTIONS to Access-Control-Allow-Methods is almost never useful for browsers, since preflight behavior isn’t governed by that header. And allowing http origins should be restricted to localhost; otherwise, users are exposed to MitM risks, which have been demonstrated in detail in public security talks.

Takeaways

Decide whether an origin is allowed before hitting your route code, and short-circuit OPTIONS with the correct headers when it is. For regular requests, attach CORS headers only after confirming the origin. Keep the default allowlist for known local addresses, read dynamic domains from storage, and return a clear 403 for preflight attempts from unauthorized origins. This small restructuring prevents the silent failure where the browser drops your call long before it reaches your FastAPI endpoint.