pythoncorsbackendfastapifastapi-middleware

How to handle dynamic origin in FastAPI


I'm implementing a FastAPI backend where I want to allow CORS requests only from a predefined list of default origins and dynamically loaded origins stored in the database.

However, when the frontend tries to call a GET API, the browser first sends an OPTIONS preflight request (as expected for CORS). The problem is:

The OPTIONS request fails due to a CORS error.

As a result, the actual GET request is never made.

This issue suggests that the CORS middleware is not properly handling the dynamic origins, especially during the OPTIONS preflight phase.

from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.base import BaseHTTPMiddleware,RequestResponseEndpoint

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

class DynamicCORSMiddleware(BaseHTTPMiddleware):
    def __init__(self, app: ASGIApp):
        super().__init__(app)
    
    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
        origin = request.headers.get("origin")
        ic(origin)
        ic(request.method)
        # Preflight request
        if request.method == "OPTIONS":
            response = JSONResponse(content={"status": "ok"})
        else:
            response = await call_next(request)

        # If no Origin header, skip CORS (non-browser requests)
        if not origin:
            return response
     
        if origin in origins:
            ic("Origin in default origins")
            response.headers["Access-Control-Allow-Origin"] = origin
            response.headers["Access-Control-Allow-Credentials"] = "true"
            response.headers["Access-Control-Allow-Headers"] = "*"
            response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
            response.headers["Vary"] = "Origin"
            return response
        
        # Extract domain (remove protocol and port)
        ic(origin)
        domain = origin.replace("https://", "").replace("http://", "")
        ic(domain)
        # Check if domain exists in database 
        domain_exists = domain_collection.find_one({"$or": [{"domain.main_domain": domain}, {"domain.sub_domain": domain}]})
        if not domain_exists:
            raise HTTPException(
                status_code=403,
                detail=f"Domain {domain} not authorized"
            )
        ic(domain_exists)
        
        # Process request
        response = await call_next(request)
        
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers["Access-Control-Allow-Credentials"] = "true"
        response.headers["Access-Control-Allow-Headers"] = "*"
        response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
        response.headers["Vary"] = "Origin"
        ic(response)
        return response
    enter code here

app = FastAPI(
    title="API",
    description="API documentation for backend",
    version="1.0.0",
    docs_url=None,  # Disable the default docs URL
    redoc_url=None  # Disable the default redoc URL
)
app.add_middleware(DynamicCORSMiddleware)

Solution

  • I have already explained the issue on comment. Here is the code with solution:

    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
    
    client = MongoClient("mongo_client_url")
    domain_collection = client.your_db.your_collection
    
    origins = [
        "http://localhost:3000",
        "http://localhost:3001",
        "http://localhost:8006",
    ]
    
    class DynamicCORSMiddleware(BaseHTTPMiddleware):
        def __init__(self, app: ASGIApp):
            super().__init__(app)
    
        async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
            origin = request.headers.get("origin")
            allow_origin = None
    
            if origin in origins:
                allow_origin = origin
            else:
                domain = re.sub(r"https?://", "", origin or "")
                domain = domain.split(":")[0]
                if domain:
                    domain_exists = domain_collection.find_one({
                        "$or": [
                            {"domain.main_domain": domain},
                            {"domain.sub_domain": domain}
                        ]
                    })
                    if domain_exists:
                        allow_origin = origin
    
            if request.method == "OPTIONS":
                if allow_origin:
                    return self._cors_preflight_response(allow_origin)
                return JSONResponse(status_code=403, content={"detail": "CORS preflight failed: Unauthorized origin"})
    
            response = await call_next(request)
    
            if allow_origin:
                self._add_cors_headers(response, allow_origin)
    
            return response
    
        def _add_cors_headers(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 _cors_preflight_response(self, origin):
            response = JSONResponse(content={"status": "ok"})
            self._add_cors_headers(response, origin)
            return response