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:

  1. Credentials silently disabled. CORS explicitly forbids wildcard origin + allow_credentials=True. The browser sends the request without cookies or Authorization header, and your auth silently fails. You get 401s and cannot figure out why.

  2. 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.

  3. 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-Control and Last-Event-ID in allow_headers if you support SSE resume.
  • Include X-Accel-Buffering in expose_headers (you set this in the response to disable Nginx buffering).
  • Make sure OPTIONS is in allow_methods explicitly, 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?

  1. Wildcard + credentials conflict. Browsers reject Access-Control-Allow-Origin: * when Access-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.

  2. 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 set max_age=0 during testing.

  3. Case-sensitive header names. Authorization vs authorization is 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 your allow_headers list.

What to do Monday morning

  1. Remove allow_origins=["*"] from your CORS config. Replace with an explicit list loaded from env.
  2. Set allow_credentials=True if your frontend sends Authorization headers or cookies.
  3. List every custom header your API uses in allow_headers (including Authorization, Content-Type, X-Request-ID).
  4. List every header your frontend JavaScript needs to read in expose_headers.
  5. Set max_age=600 for production (10 minutes). Set max_age=0 during local debugging to see changes immediately.
  6. Test with curl: curl -X OPTIONS -H 'Origin: https://app.yourservice.com' -H 'Access-Control-Request-Method: POST' https://api.yourservice.com/chat -v and 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

  1. Never use allow_origins=["*"] in production. It silently disables credentials and fails most compliance audits.
  2. Load origins from environment via Pydantic Settings. Dev, staging, and production each get their own allowlist.
  3. Set allow_credentials=True and list Authorization in allow_headers for any API that uses token auth.
  4. Expose custom response headers via expose_headers. Your frontend cannot read them otherwise.
  5. max_age=600 caches preflight for 10 minutes in production; set 0 during debugging. A stale preflight cache is the #1 source of "but my config looks right" errors.
  6. 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.

Share this post

Continue Reading

Weekly Bytes of AI

Technical deep-dives for engineers building production AI systems.

Architecture patterns, system design, cost optimization, and real-world case studies. No fluff, just engineering insights.

Unsubscribe anytime. We respect your inbox.

Ready to go deeper?

Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.