Search Topics
Search across all FastAPI topics
CORS
Core ConceptCross-Origin Resource Sharing controls which frontend domains can call your API. Without it, browsers block requests from any origin other than your API's own domain.
Your React frontend deployed to myapp.com calls your FastAPI backend at api.myapp.com. Everything worked in local dev. In production: CORS error. You add allow_origins=['*'] with allow_credentials=True. Still broken.
# Browser console: Access to fetch at 'https://api.myapp.com/users' from origin 'https://myapp.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. # You thought '*' means "allow everything". # But with credentials, '*' is explicitly FORBIDDEN.
Question
Why would "allow everything" not actually allow everything? Because CORS isn't a server-side feature — it's a browser security policy. The browser enforces the rules, and it has opinions about wildcards and credentials that your server can't override.
What's Actually Happening
CORS isn't your server blocking requests. Your server is happy to respond to anyone. It's the browser that blocks the response from reaching your JavaScript. Here's the flow:
Your JS Code
fetch('api.myapp.com')
Browser Check
Different origin? Ask server first
Server Response
Here are my CORS headers
Browser Decision
Headers OK? Allow or block
The server always sends the response. The browser just decides whether your JavaScript gets to see it. That's why CORS errors never happen with curl or Postman — they're not browsers.
The Mental Model
What you just learned
CORS is enforced by the browser, not the server
Different ports, subdomains, or protocols all count as different origins
The server tells the browser what's allowed via response headers
Non-browser tools (curl, Postman) skip CORS entirely
What Counts as a Different Origin?
An "origin" is the combination of scheme + host + port. Change any one of these and the browser treats it as a different origin. This trips up a lot of people in local development.
# These are ALL different origins:
http://localhost:3000 # React dev server
http://localhost:8000 # FastAPI server
http://localhost:5173 # Vite dev server
# Even these are different:
http://myapp.com # HTTP
https://myapp.com # HTTPS (different scheme!)
https://api.myapp.com # Different subdomain
https://myapp.com:8443 # Different port
# So your React app at localhost:3000 calling
# FastAPI at localhost:8000 is a cross-origin request.
# Even in local dev!Insight
This is why "it works in development but not production" is the most common CORS complaint. In dev, your frontend and backend might share localhost but use different ports. In production, they might have different subdomains. Both are cross-origin.
Setting Up CORSMiddleware
FastAPI includes CORSMiddleware from Starlette. You tell it which origins, methods, and headers to allow. Here's the basic setup:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
# Which origins can call your API
allow_origins=["http://localhost:3000"],
# Allow cookies and auth headers
allow_credentials=True,
# Which HTTP methods are allowed
allow_methods=["*"],
# Which request headers the client can send
allow_headers=["*"],
)
@app.get("/api/items")
async def list_items():
return [{"id": 1, "name": "Item 1"}]Configuration Options
Each parameter controls a different aspect of cross-origin access. Let's walk through what each one does.
app.add_middleware(
CORSMiddleware,
# Which origins can make requests
allow_origins=[
"http://localhost:3000", # Local dev
"https://myapp.com", # Production
"https://staging.myapp.com", # Staging
],
# Allow cookies and auth headers
allow_credentials=True,
# Which HTTP methods are allowed
allow_methods=["GET", "POST", "PUT", "DELETE"],
# Which headers the client can send
allow_headers=["Authorization", "Content-Type"],
# Which response headers the client can READ
expose_headers=["X-Total-Count", "X-Request-ID"],
# How long the browser caches preflight results (seconds)
max_age=600, # 10 minutes
)Configuration
What you just learned
allow_origins lists the exact domains that can call your API
allow_credentials enables cookies and auth headers across origins
expose_headers controls which response headers your JS can read
max_age tells the browser how long to cache preflight results
The Wildcard Trap
This is the trap from the failure hook at the top of this page. Using allow_origins=["*"] seems like it allows everything. But the browser has a strict rule: you cannot combine wildcards with credentials.
# Public API — no auth cookies needed
# This works fine with the wildcard
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Any origin
allow_credentials=False, # No cookies
allow_methods=["GET"],
allow_headers=["*"],
)
# Private API — needs auth cookies
# You MUST list specific origins
app.add_middleware(
CORSMiddleware,
allow_origins=["https://myapp.com"], # Specific!
allow_credentials=True, # Cookies work
allow_methods=["*"],
allow_headers=["*"],
)
# THIS DOES NOT WORK — browser rejects it
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Wildcard...
allow_credentials=True, # ...with credentials = BROKEN
)Wildcard + Credentials = Silent Failure
You deploy to production with allow_origins=['*'] and allow_credentials=True. The API works from Postman but every browser request fails.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # "Allow everything!"
allow_credentials=True, # "Allow cookies too!"
allow_methods=["*"],
allow_headers=["*"],
)Preflight Requests
For some requests, the browser sends an extra OPTIONS request before your actual request. This is called a "preflight" — the browser checks if the server will accept the real request before actually sending it.
Your JS
POST with JSON body
Browser
Non-simple request — preflight!
OPTIONS /api
Browser asks: 'Is this allowed?'
Server
Returns CORS headers
POST /api
Now the real request goes through
# The browser automatically sends this before your actual request:
# OPTIONS /api/items HTTP/1.1
# Origin: http://localhost:3000
# Access-Control-Request-Method: POST
# Access-Control-Request-Headers: Content-Type
# CORSMiddleware responds with:
# HTTP/1.1 200 OK
# Access-Control-Allow-Origin: http://localhost:3000
# Access-Control-Allow-Methods: POST
# Access-Control-Allow-Headers: Content-Type
# Access-Control-Max-Age: 600
# Only then does the browser send the actual POST request.
# You don't need to handle OPTIONS yourself — the middleware does it.Watch out
Preflight requests can slow down your API if max_age is too low. Every unique combination of origin + method + headers triggers a new preflight. Set max_age=600 (10 minutes) or higher so the browser caches the preflight result.
How CORS Works
What you just learned
Wildcard origins ('*') cannot be used with allow_credentials=True
The browser sends a preflight OPTIONS request for non-simple requests
POST with Content-Type: application/json triggers a preflight
max_age controls how long the browser caches preflight results
Try It: CORS Simulator
Select a scenario to see how the browser handles cross-origin requests. Watch the request flow, header check, and whether the browser allows or blocks the response.
Go Deeper: Production CORS Setup
In production, you'll typically have multiple environments (dev, staging, prod) each with different origins. Here's a pattern that scales:
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Environment-aware CORS configuration
CORS_ORIGINS = {
"development": [
"http://localhost:3000",
"http://localhost:5173",
],
"staging": [
"https://staging.myapp.com",
],
"production": [
"https://myapp.com",
"https://www.myapp.com",
],
}
env = os.getenv("ENV", "development")
origins = CORS_ORIGINS.get(env, CORS_ORIGINS["development"])
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["*"],
max_age=600,
)Think about it...
A simple GET request with no custom headers does NOT trigger a preflight OPTIONS request. But a POST with Content-Type: application/json DOES. Why?
Hint: Think about what content types HTML forms can send natively.
Key Points
CORSMiddleware
Built-in middleware that handles cross-origin requests and preflight
allow_origins
List specific origins or use ["*"] for public APIs without credentials
Credentials
Cannot use wildcard origins when allow_credentials is True
Preflight
OPTIONS requests are handled automatically by the middleware