FastAPI CORSMiddleware: allow_credentials Without a Wildcard
Failure symptom:
Access to fetch at 'https://api.orbitmail.dev/v1/messages' from origin
'https://console.orbitmail.dev' 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'.
The maddening part is that this error is usually intermittent. The same page, the same endpoint, the same build: it works right after signing in, then fails in an incognito window or on the very first load after a cookie expiry. Nothing in your code changed between the two calls.
Root Cause
The configuration that produces the intermittent behaviour is allow_origins=["*"] combined with allow_credentials=True. Starlette’s CORSMiddleware — which FastAPI re-exports as fastapi.middleware.cors.CORSMiddleware — precomputes a static header set at startup. When every origin is allowed, that static set contains Access-Control-Allow-Origin: *. On the way out, the middleware only replaces that wildcard with the caller’s exact origin if the request carried a Cookie header. A browser request made with credentials: "include" but an empty cookie jar sends no Cookie header at all, so the wildcard survives — and the browser refuses a wildcard whenever the credentials mode is include, exactly as the WHATWG Fetch Standard requires. The preflight takes a different code path and does echo the origin, so the OPTIONS request passes and only the real request is blocked, which is why the Network panel shows a green preflight above a red POST.
This page is part of Framework CORS Middleware Configuration, which covers the middleware layer where most production CORS policy actually lives.
The divergence between the two round trips is worth tracing once, because it explains every “but the preflight is fine!” report you will ever get about a FastAPI service:
Prerequisite State
Before applying the fix, confirm the following:
- FastAPI is installed with a recent Starlette (
pip show starlette), and the service is served by Uvicorn or Hypercorn. - The browser code sends
credentials: "include"(fetch) orwithCredentials = true(XHR/axios) — otherwise you do not needallow_credentialsat all and should turn it off. - The session cookie itself is issued with
SameSite=None; Secure, or the browser will not attach it to a cross-site request no matter what CORS says. The trade-offs are covered in SameSite=None vs CORS Credentials: The Tradeoffs. - You know the exact origin strings the frontend is served from, scheme and port included.
https://console.orbitmail.devandhttps://console.orbitmail.dev:443are the same origin;http://localhost:5173andhttp://127.0.0.1:5173are not, as Why localhost and 127.0.0.1 Are Different Origins explains.
Step-by-Step Fix
Step 1 — Replace the wildcard with an explicit allowlist
Name every origin. Load the list from settings so staging and production differ by configuration rather than by code.
# app/settings.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Comma-separated in the environment, e.g.
# CORS_ORIGINS="https://console.orbitmail.dev,https://admin.orbitmail.dev"
cors_origins: str = "http://localhost:5173"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
settings = Settings()
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.settings import settings
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
expose_headers=["X-Request-ID", "X-RateLimit-Remaining"],
max_age=600,
)
With a concrete list in place, the middleware compares the incoming Origin against it with an exact string match and echoes the matched value — the same discipline described in Dynamic Origin Validation Patterns. It also adds Vary: Origin automatically once it is echoing rather than wildcarding, which keeps shared caches honest.
Every keyword argument above maps to exactly one response header, and knowing the mapping turns header debugging into a one-line configuration edit:
Step 2 — Add CORSMiddleware last so it runs first
add_middleware inserts each layer at the top of the stack, so the last call wraps everything before it. That inversion is the second-most-common FastAPI CORS bug: an authentication middleware registered after CORSMiddleware becomes the outermost layer, sees a credential-free OPTIONS preflight, and returns 401 before the CORS layer ever runs.
# app/main.py — correct order
app = FastAPI()
app.add_middleware(RequestIDMiddleware) # innermost at runtime
app.add_middleware(SessionAuthMiddleware) # runs second
app.add_middleware(CORSMiddleware, **cors_kwargs) # added last -> runs FIRST
If SessionAuthMiddleware must stay outermost for some other reason, it has to let the preflight through itself:
class SessionAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.method == "OPTIONS":
return await call_next(request) # never authenticate a preflight
...
The two orderings look almost identical in the source file and behave completely differently at runtime:
Step 3 — Declare the header contract instead of inheriting defaults
Starlette’s defaults are deliberately narrow: allow_methods defaults to ("GET",) and allow_headers to the empty tuple, on top of the CORS-safelisted request headers. A POST of JSON with a bearer token matches neither default, so the preflight fails on both counts. The table below is the full parameter surface.
| Parameter | Type | Default | What it controls |
|---|---|---|---|
allow_origins |
Sequence[str] |
() |
Exact origin strings compared byte for byte against Origin |
allow_origin_regex |
str | None |
None |
Compiled pattern matched against Origin when the exact list misses |
allow_methods |
Sequence[str] |
("GET",) |
Values echoed in Access-Control-Allow-Methods; ["*"] expands to all methods |
allow_headers |
Sequence[str] |
() |
Added to the safelisted set for Access-Control-Allow-Headers |
allow_credentials |
bool |
False |
Emits Access-Control-Allow-Credentials: true and forces an explicit origin on preflight |
expose_headers |
Sequence[str] |
() |
Response headers JavaScript is permitted to read |
max_age |
int |
600 |
Seconds in Access-Control-Max-Age |
Two details in that table cause real outages. First, allow_headers=["*"] under credentials reflects whatever the browser asked for, which quietly defeats the point of listing headers at all — enumerate them instead. Second, max_age is a request, not a promise: Chromium caps a preflight entry at 7200 seconds regardless of what you send, as How to Set Access-Control-Max-Age Effectively sets out.
Step 4 — Make the browser call match
const res = await fetch("https://api.orbitmail.dev/v1/messages", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-Request-ID": crypto.randomUUID(),
},
body: JSON.stringify({ subject: "Weekly digest" }),
});
X-Request-ID is not safelisted, so this call preflights. Because the header is named in both allow_headers and expose_headers, the preflight passes and the response copy of the header is readable from JavaScript.
Verification
Run the preflight and the actual request separately — they exercise different branches of the middleware, and only checking one is how the wildcard bug survives review.
# 1) Preflight: expect 200, an exact origin echo, and vary: origin
curl -sS -i -X OPTIONS https://api.orbitmail.dev/v1/messages \
-H 'Origin: https://console.orbitmail.dev' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-request-id' \
| grep -iE '^(HTTP|access-control|vary)'
# 2) Actual request WITHOUT a cookie: the echo must still be exact, never *
curl -sS -i -X POST https://api.orbitmail.dev/v1/messages \
-H 'Origin: https://console.orbitmail.dev' \
-H 'Content-Type: application/json' -d '{"subject":"probe"}' \
| grep -iE '^(access-control|vary)'
# 3) An origin that is not on the list must produce no access-control headers
curl -sS -i -X OPTIONS https://api.orbitmail.dev/v1/messages \
-H 'Origin: https://console.orbitmail.dev.attacker.example' \
-H 'Access-Control-Request-Method: POST' | grep -ci access-control-allow-origin
Work through the checklist before closing the ticket:
Security Boundary Note
Do not reach for allow_origin_regex=".*" as a shortcut once you discover the wildcard is refused. It produces exactly the open policy the browser was protecting you from, only through a route the linters do not recognise: every origin on the internet matches, credentials are attached, and any page the victim visits can read authenticated responses from your API. If preview deployments genuinely need dynamic origins, anchor the pattern and escape the dots — r"https://[a-z0-9-]+\.preview\.orbitmail\.dev" — and never combine an unanchored pattern with allow_credentials=True. The failure modes of the permissive end of this spectrum are catalogued in Wildcard CORS Risks and Safe Origin Allowlisting.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
allow_origins=["*"] with allow_credentials=True |
Cookie-bearing requests pass, cookie-free ones return * and are blocked — an intermittent failure that looks like a frontend bug |
List the origins explicitly; never mix a wildcard with credentials |
CORSMiddleware added before an auth middleware |
The auth layer wraps CORS, answers the preflight 401, and strips every Access-Control header |
Add CORSMiddleware last, or exempt OPTIONS inside the auth layer |
Relying on the default allow_methods=("GET",) |
The preflight for POST or DELETE is rejected with a method failure the console reports as a generic block |
Enumerate every method the router serves |
| Unhandled exception in a route handler | The 500 is generated above the middleware and carries no CORS headers, so the browser reports a CORS error instead of the real trace |
Register an exception handler that returns a normal Response object |
FAQ
Why does the request succeed while I am logged in and fail after I clear cookies?
Because Starlette’s CORSMiddleware only downgrades a wildcard to an explicit origin when the incoming request actually carries a Cookie header. With allow_origins set to a star and allow_credentials enabled, a request that happens to carry a session cookie gets the exact origin echoed and passes, while the same call with an empty cookie jar gets Access-Control-Allow-Origin: * and is blocked, because the credentials mode is still include. Naming the origins explicitly removes the branch entirely.
Does allow_origin_regex let me support preview deployments safely?
Yes, provided the pattern is anchored at both ends and escapes the dots. allow_origin_regex is compiled with re.compile and matched with fullmatch semantics against the Origin header, so a pattern such as https://[a-z0-9-]+\.preview\.orbitmail\.dev admits generated preview hosts and nothing else. An unanchored pattern containing a bare dot matches attacker-controlled hosts, so review the regex the same way you would review an allowlist entry.
Why do my 500 responses arrive without any Access-Control headers?
An unhandled exception propagates past CORSMiddleware to Starlette’s outermost error handler, which builds a fresh plain-text 500 response that CORSMiddleware never decorates. The browser then reports a CORS failure instead of the real server error. Catch the exception in an exception handler that returns a normal Response, so the response travels back down through the middleware stack and picks up the headers.