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:

Preflight passes, credentialed request blocked A two-lane sequence between the browser and a FastAPI service configured with a wildcard origin and credentials enabled. The OPTIONS preflight is answered with the exact origin echoed, but the following POST carries no Cookie header, so the response falls back to a wildcard and the browser blocks the read. Browser tab on the console FastAPI + CORSMiddleware 1. OPTIONS /v1/messages with Access-Control-Request-Method: POST 200 with the exact origin echoed — the preflight path always echoes 2. POST /v1/messages, credentials: include, cookie jar empty 200 with Access-Control-Allow-Origin: * — no Cookie header arrived The browser discards the body: a wildcard is never acceptable under credentials mode include Sign in first and a Cookie header appears, the wildcard is replaced, and the same call succeeds

Prerequisite State

Before applying the fix, confirm the following:

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:

CORSMiddleware arguments and the headers they emit Six keyword arguments of Starlette's CORSMiddleware are listed on the left, each joined by a horizontal arrow to the one response header it produces on the right. A footnote records that Vary: Origin is added automatically when the origin is echoed. add_middleware keyword response header written allow_origins=[...] Access-Control-Allow-Origin allow_credentials=True Allow-Credentials: true allow_methods=[...] Allow-Methods (preflight only) allow_headers=[...] Allow-Headers (preflight only) expose_headers=[...] Expose-Headers (actual only) max_age=600 Max-Age: 600 (preflight only) Vary: Origin is appended automatically as soon as the middleware echoes an origin instead of a wildcard

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:

Declaration order versus execution order Three middleware declarations are listed in source order on the left and in runtime order on the right, connected by arrows that show the list is reversed. The layer added last becomes the outermost one and answers the OPTIONS preflight first. order written in main.py order an OPTIONS request meets 1. RequestIDMiddleware added first 2. SessionAuthMiddleware added second 3. CORSMiddleware added last CORSMiddleware answers the preflight here SessionAuthMiddleware never sees the OPTIONS RequestIDMiddleware innermost, closest to routes Swap lines 2 and 3 and the preflight reaches SessionAuthMiddleware first, which answers 401 with no Access-Control headers at all — the console then blames CORS for an auth bug

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.