If you build APIs with FastAPI, you’re going to touch CORS almost immediately. Usually right after your frontend starts throwing blocked by CORS policy in DevTools and everyone suddenly becomes a browser networking expert.
FastAPI makes CORS easy enough to turn on, but the hard part is choosing the right setup. There’s a big difference between “make the error go away” and “configure cross-origin access without creating a mess.”
Here’s the practical comparison guide I wish more teams used.
The baseline: FastAPI’s built-in CORSMiddleware
FastAPI relies on Starlette’s CORSMiddleware, and for most apps, that’s the right starting point.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = [
"https://app.example.com",
"https://admin.example.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
expose_headers=["X-Request-Id"],
)
This handles:
Access-Control-Allow-Origin- preflight
OPTIONSrequests Access-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-CredentialsAccess-Control-Expose-Headers
For a lot of teams, this is enough.
Option 1: Allow specific origins
This is the sane default for production.
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://app.example.com",
"https://staging.example.com",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
Pros
- Tight control over who can call your API from a browser
- Works well with cookie auth and session-based flows
- Easy to reason about during audits
- Lower chance of accidentally exposing authenticated endpoints cross-origin
Cons
- Annoying in multi-environment setups
- Easy to forget preview URLs, local dev ports, or staging domains
- Can become config sprawl if your frontend deployment model changes often
My take
If your API is used by one or two known browser apps, do this and move on. It’s boring, and boring is good in security config.
Option 2: Wildcard origin with *
This is common for public APIs, and sometimes exactly the right choice.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET"],
allow_headers=["*"],
)
GitHub is a useful real-world example here. api.github.com returns:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That makes sense for a broadly consumable API. It’s public, heavily documented, and intentionally usable from many browser clients.
Pros
- Dead simple
- Great for public, read-only APIs
- No need to maintain origin lists
- Fewer deployment headaches
Cons
- Wrong choice for cookie-authenticated APIs
- Sends a broad signal that any site can make browser-based requests
- Can hide bad API boundary decisions
- You cannot combine
allow_credentials=Truewith wildcard origins in the browser model
My take
If your API is public and doesn’t rely on browser credentials, wildcard CORS is fine. I’d still keep methods and headers tighter than * unless I have a strong reason not to.
Option 3: Regex-based origins
FastAPI also supports allow_origin_regex, which is handy when you have many subdomains.
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https://.*\.example\.com",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Pros
- Cleaner than maintaining a giant allowlist
- Good for tenant subdomains
- Useful for ephemeral preview deployments if your naming is predictable
Cons
- Easy to get wrong
- Regex mistakes become security bugs
- Harder for other developers to verify at a glance
- Broad patterns age badly
My take
I use regex sparingly. It feels elegant until someone adds foo.example.com.evil-site.net to a poorly anchored pattern or broadens it without understanding the fallout. If you do this, keep the regex strict and tested.
A safer pattern would be something like:
allow_origin_regex=r"^https://([a-z0-9-]+\.)?example\.com$"
Even then, I prefer explicit allowlists when the number of origins is manageable.
Option 4: Dynamic origin reflection
Some teams want to check the Origin header at runtime against a database, config service, or tenant registry. FastAPI’s stock middleware doesn’t really give you a rich policy engine for that, so people write custom middleware.
Example:
from fastapi import FastAPI, Request
from starlette.responses import Response
app = FastAPI()
ALLOWED_ORIGINS = {
"https://app.example.com",
"https://tenant1.example.com",
"https://tenant2.example.com",
}
@app.middleware("http")
async def custom_cors(request: Request, call_next):
response = await call_next(request)
origin = request.headers.get("origin")
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin"
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
if request.method == "OPTIONS" and origin in ALLOWED_ORIGINS:
return Response(
status_code=204,
headers={
"Access-Control-Allow-Origin": origin,
"Vary": "Origin",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
},
)
return response
Pros
- Maximum flexibility
- Works for tenant-aware SaaS cases
- Lets you integrate policy with your app’s business logic
Cons
- Easy to break preflight handling
- Easy to forget
Vary: Origin - More code, more tests, more edge cases
- You’re now maintaining security middleware yourself
My take
I only reach for custom CORS logic when there’s a real multi-tenant need. Otherwise it’s self-inflicted complexity. The built-in middleware is less glamorous and much harder to screw up.
Credentials: where most CORS mistakes happen
If you use cookies or browser-managed auth, CORS gets stricter fast.
This combination is invalid for browsers:
allow_origins=["*"]
allow_credentials=True
If credentials are allowed, the server must return a specific origin, not *.
That means this is the pattern you want:
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token"],
)
And no, CORS is not CSRF protection. People still blur those together. CORS controls which browser origins can read responses. CSRF is about whether a browser can be tricked into sending authenticated requests. Different problem entirely.
Exposed headers: useful and often forgotten
Browsers don’t automatically expose every response header to frontend JavaScript. If your app needs to read custom headers, add them explicitly.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET"],
allow_headers=["Authorization", "Content-Type"],
expose_headers=["X-Request-Id", "X-RateLimit-Remaining"],
)
GitHub does this well. Their exposed header list includes practical stuff like ETag, Link, Retry-After, and rate limit headers. That’s a good model: expose what clients actually need, not every header you happen to send.
Dev vs prod: don’t copy-paste the same CORS policy
A common pattern:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
environment: str = "development"
settings = Settings()
if settings.environment == "development":
origins = ["http://localhost:3000", "http://127.0.0.1:5173"]
else:
origins = ["https://app.example.com"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
That’s fine. What I don’t like is teams leaving dev wildcards in production because “we’ll tighten it later.” They won’t.
Which approach should you choose?
Here’s the short version:
Specific origin allowlist
Best for:
- internal apps
- SPAs with login
- admin dashboards
- anything using cookies
Tradeoff:
- more maintenance, better security
Wildcard *
Best for:
- public APIs
- read-only browser access
- anonymous endpoints
Tradeoff:
- easiest setup, weakest origin restriction
Regex origins
Best for:
- structured subdomain fleets
- preview environments with predictable hostnames
Tradeoff:
- compact config, higher risk of pattern mistakes
Custom dynamic CORS
Best for:
- multi-tenant SaaS with per-tenant browser origins
- advanced runtime policy needs
Tradeoff:
- maximum flexibility, maximum foot-gun potential
A few practical rules I stick to
- Default to explicit origins in production.
- Avoid custom middleware unless the built-in one genuinely can’t model your policy.
- If credentials are involved, never use wildcard origins.
- Expose only the headers your frontend actually reads.
- Test preflight requests, not just normal
GETs.
When I want to quickly inspect a live API’s headers or verify whether a preflight response is sane, I’ll use a header inspection tool like headertest.com. Saves time versus manually piecing everything together from browser tabs.
And if your conversation starts drifting into broader response hardening, CORS is only one piece. Headers like CSP, HSTS, and X-Content-Type-Options matter too. If you’re tuning the full header set, csp-guide.com is worth a look for the CSP side of the house.
CORS in FastAPI is not hard. Choosing the least dangerous configuration for your app is the real job. If the API is private or authenticated, keep the origin list tight. If it’s public, wildcard may be perfectly reasonable. Just make that decision on purpose, not because the browser yelled at you five minutes before deploy.