CORS Configuration
CORS (Cross-Origin Resource Sharing) controls which browser origins can call your API. Without it, browsers block requests from a frontend at app.example.com to an API at api.example.com. FastAPI includes CORS middleware out of the box.
Learning Focus
By the end of this lesson you can: configure CORS with allowed origins, enable credentials, restrict methods and headers, and safely manage origin lists across environments.
Adding CORSMiddleware
app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
)
CORS Options Reference
| Option | Type | Description |
|---|---|---|
allow_origins | list[str] | Exact allowed origins or ["*"] |
allow_origin_regex | str | Regex pattern for origins |
allow_credentials | bool | Allow cookies/auth headers |
allow_methods | list[str] | HTTP methods or ["*"] |
allow_headers | list[str] | Allowed request headers or ["*"] |
expose_headers | list[str] | Headers browsers can access in response |
max_age | int | Preflight cache duration (seconds) |
Environment-Based Origin Lists
app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import field_validator
class Settings(BaseSettings):
ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"]
@field_validator("ALLOWED_ORIGINS", mode="before")
@classmethod
def parse_origins(cls, v):
if isinstance(v, str):
return [o.strip() for o in v.split(",")]
return v
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
.env
ALLOWED_ORIGINS=http://localhost:3000,https://staging.example.com,https://app.example.com
app/main.py
from app.core.config import settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Wildcard Origins with Credentials
warning
allow_origins=["*"] and allow_credentials=True cannot be used together. Browsers reject this combination. For credentials, always list specific origins.
app/main.py
# ❌ This combination will not work — browsers reject it
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True, # Conflict!
)
# ✅ Correct: specific origins with credentials
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
)
Dynamic Origin Validation
For multi-tenant apps where origins are stored in a database:
app/middleware/cors_dynamic.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
ALLOWED_ORIGINS: set[str] = set()
async def refresh_allowed_origins(db: AsyncSession) -> None:
"""Call at startup to load origins from DB."""
global ALLOWED_ORIGINS
from sqlalchemy import select
result = await db.execute(select(TenantOrigin.origin))
ALLOWED_ORIGINS = set(result.scalars().all())
class DynamicCORSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
origin = request.headers.get("origin", "")
response = await call_next(request)
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin"
return response
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| CORS error in browser but not curl | curl doesn't send Origin header | Test with browser dev tools or use --header "Origin: http://..." |
| Preflight OPTIONS returns 405 | CORS middleware not configured | Ensure CORSMiddleware is added before routers |
| Credentials not sent | Forgot allow_credentials=True or frontend credentials: "include" | Both server and client must opt in |
* origin not working for auth | Browser blocks Authorization header with * | List specific origins when using auth headers |
| Origin list not updating after deploy | Config read at startup | Restart the app or use dynamic CORS middleware |
Hands-On Practice
app/main.py
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Read from environment; fall back to localhost for development
allowed_origins = os.getenv(
"ALLOWED_ORIGINS",
"http://localhost:3000,http://localhost:5173"
).split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in allowed_origins],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
expose_headers=["X-Request-ID", "X-Response-Time"],
max_age=600,
)
@app.get("/")
async def root() -> dict:
return {"status": "ok"}
test-cors.sh
uvicorn app.main:app --reload
# Simulate browser preflight
curl -v -X OPTIONS http://localhost:8000/ \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization"
# Should see:
# Access-Control-Allow-Origin: http://localhost:3000
# Access-Control-Allow-Credentials: true