FastAPI CORS for production agentic APIs
Your frontend gets CORS errors and you set allow_origins=["*"] to make them go away
Your agent API lives on api.yourservice.com. Your frontend lives on app.yourservice.com. The browser blocks every request with "CORS error." You add CORSMiddleware(allow_origins=["*"]) to make it work. It works. Then you realize the API now accepts requests from any origin on the internet, your credentials flag is ignored, and your streaming endpoint is still broken because SSE preflight is subtly wrong.
This post is the production CORS pattern for agentic APIs: explicit origin allowlist, credentials that actually work, preflight headers that do not break SSE streaming, and the 3 gotchas that every agent service hits.
Why is allow_origins=["*"] dangerous?
Because it opens your API to every website on the internet. 3 specific failure modes:
-
Credentials silently disabled. CORS explicitly forbids wildcard origin +
allow_credentials=True. The browser sends the request without cookies orAuthorizationheader, and your auth silently fails. You get 401s and cannot figure out why. -
XSS attack surface multiplied. Any malicious site can trigger requests to your API on behalf of a logged-in user. With credentials disabled you are slightly safer, but a cookie-based session is still vulnerable.
-
Compliance fail. Most security audits fail a service with wildcard CORS. If you need SOC 2 or similar, this is an audit finding.
The fix: explicit allowlist of trusted origins, credentials enabled, preflight configured for your actual methods and headers.
graph LR
Browser[Browser on app.yourservice.com] --> Preflight[OPTIONS preflight]
Preflight --> API[API on api.yourservice.com]
API --> CORSCheck{Origin in allowlist?}
CORSCheck -->|yes| Allow[200 + Access-Control headers]
CORSCheck -->|no| Reject[400 CORS error]
Allow --> Real[Real POST request]
style CORSCheck fill:#fef3c7,stroke:#b45309
style Allow fill:#dcfce7,stroke:#15803d
style Reject fill:#fee2e2,stroke:#b91c1c
What does the correct CORS setup look like?
Explicit origin allowlist from config, allow_credentials=True, and a specific method/header list. Read origins from environment so dev, staging, and production each use their own allowlist.
# filename: app/main.py
# description: Production CORS for a FastAPI agentic API.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
app = FastAPI()
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins, # list from env, e.g. ['https://app.yourservice.com']
allow_credentials=True,
allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allow_headers=['Authorization', 'Content-Type', 'X-Request-ID'],
expose_headers=['X-Request-ID', 'X-Token-Expired'],
max_age=600,
)
5 decisions to note. allow_origins reads from config, never hardcoded, and never ["*"]. allow_credentials=True is required if your frontend sends Authorization headers or cookies. allow_headers is explicit; wildcard would disable credentials. expose_headers controls which response headers the browser JavaScript can read. max_age=600 tells the browser to cache the preflight for 10 minutes.
For the Pydantic Settings pattern that loads cors_origins from env, see the Environment variable parsing for Python AI services post.
How do you handle multiple environments?
Store origins in env variables as a comma-separated list, parse into a Python list in Settings. Each environment sets its own list.
# filename: app/config.py
# description: CORS origins loaded per environment.
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
cors_origins: list[str] = Field(
default_factory=lambda: [],
alias='CORS_ORIGINS',
)
class Config:
env_file = '.env'
In .env.development:
CORS_ORIGINS=["http://localhost:3000","http://localhost:3001"]
In .env.production:
CORS_ORIGINS=["https://app.yourservice.com","https://admin.yourservice.com"]
Pydantic parses the JSON-formatted list on load. No manual string splitting.
What is the SSE streaming gotcha?
CORS preflight caches with max_age. If a preflight response does not include Access-Control-Allow-Headers for a custom header your streaming endpoint uses, the browser cache will serve the stale preflight for 10 minutes and your streaming requests will all fail. Always include every custom header your streaming endpoint reads or writes in allow_headers and expose_headers.
Specific SSE gotchas:
- Include
Cache-ControlandLast-Event-IDinallow_headersif you support SSE resume. - Include
X-Accel-Bufferinginexpose_headers(you set this in the response to disable Nginx buffering). - Make sure
OPTIONSis inallow_methodsexplicitly, even though it is implied.
For the full SSE streaming setup in FastAPI, see the FastAPI and Uvicorn for production agentic AI systems post.
What are the 3 CORS gotchas that bite every agent project?
-
Wildcard + credentials conflict. Browsers reject
Access-Control-Allow-Origin: *whenAccess-Control-Allow-Credentials: true. You must use a specific origin. The browser error message is cryptic ("credentialed request requires non-wildcard origin") and easy to miss. -
Preflight cache poisoning. A misconfigured header in one response caches for 10 minutes (
max_age) and poisons every subsequent request for that duration. When debugging CORS, always hard-reload or setmax_age=0during testing. -
Case-sensitive header names.
Authorizationvsauthorizationis the same thing in HTTP but Starlette's CORS middleware may not normalize them consistently in older versions. Use the exact casing the browser sends (Authorization,Content-Type) in yourallow_headerslist.
What to do Monday morning
- Remove
allow_origins=["*"]from your CORS config. Replace with an explicit list loaded from env. - Set
allow_credentials=Trueif your frontend sendsAuthorizationheaders or cookies. - List every custom header your API uses in
allow_headers(includingAuthorization,Content-Type,X-Request-ID). - List every header your frontend JavaScript needs to read in
expose_headers. - Set
max_age=600for production (10 minutes). Setmax_age=0during local debugging to see changes immediately. - Test with curl:
curl -X OPTIONS -H 'Origin: https://app.yourservice.com' -H 'Access-Control-Request-Method: POST' https://api.yourservice.com/chat -vand confirm the response includes the expected headers.
The headline: CORS is an allowlist plus 5 explicit header choices. Wildcard origins are a security bug disguised as convenience. 15 minutes to fix correctly, and it stays fixed forever.
Frequently asked questions
Why is wildcard CORS (allow_origins=["*"]) dangerous?
Because it silently disables credentials: browsers refuse to send cookies or Authorization headers when the origin is wildcarded. It also opens your API to cross-site requests from any website on the internet, which is a security and compliance fail. Most audit frameworks (SOC 2, ISO 27001) will flag wildcard CORS as a finding.
How do I configure CORS for different environments?
Load cors_origins from environment variables using Pydantic Settings. Use a JSON-formatted list in .env.development for localhost origins, and a separate list in .env.production for real domain origins. Pydantic parses the JSON on startup, so your code stays identical across environments while the allowlist varies.
What headers do I need to include in allow_headers for an agent API?
At minimum: Authorization, Content-Type. Add any custom headers your client sends: X-Request-ID, Accept, X-Session-ID. For SSE streaming, also add Cache-Control and Last-Event-ID if you support resume. Match case exactly to what the browser sends, and never use wildcard with credentials enabled.
Why does my SSE streaming endpoint fail with CORS errors even though my config is correct?
Because preflight responses cache with max_age, and a previous misconfigured preflight poisoned the cache. When debugging, hard-reload the browser or set max_age=0 temporarily. Also confirm OPTIONS is explicit in allow_methods and that X-Accel-Buffering is in expose_headers if you set it on streaming responses.
Should I put CORS middleware first or last in my middleware stack?
CORS middleware should be near the outside of the stack, specifically before any middleware that could short-circuit the request with an error. In FastAPI, app.add_middleware(CORSMiddleware, ...) adds it to the outside by default. The rule: CORS needs to see the OPTIONS preflight before any auth or rate limit middleware can reject it.
Key takeaways
- Never use
allow_origins=["*"]in production. It silently disables credentials and fails most compliance audits. - Load origins from environment via Pydantic Settings. Dev, staging, and production each get their own allowlist.
- Set
allow_credentials=Trueand listAuthorizationinallow_headersfor any API that uses token auth. - Expose custom response headers via
expose_headers. Your frontend cannot read them otherwise. max_age=600caches preflight for 10 minutes in production; set0during debugging. A stale preflight cache is the #1 source of "but my config looks right" errors.- To see CORS wired into a full production agent stack with auth, streaming, and middleware ordering, walk through the Build your own coding agent course, or start with the AI Agents Fundamentals primer.
For the MDN reference on CORS, including credential handling and preflight rules, see the MDN CORS documentation. Every rule in this post maps onto something documented there in more detail.
Continue Reading
Ready to go deeper?
Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.