Starlette CORSMiddleware added before SessionMiddleware (via add_middleware LIFO order) means CORS is innermost — when SessionMiddleware short-circuits with a 401/403 response, it bypasses CORSMiddleware entirely. The browser sees a 401 with no Access-Control-Allow-Origin header, reports it as a CORS error, and the client can't even read the 401 status. Curl tests pass because curl doesn't enforce CORS. Fix: add SessionMiddleware BEFORE CORSMiddleware so CORS wraps Session (outermost in the LIFO stack). This ensures all responses — including auth rejections — get CORS headers.
Starlette's add_middleware is LIFO — the last middleware added runs outermost (first on request, last on response). CORSMiddleware must be outermost to add headers to ALL responses. Fix the ordering:
# WRONG — CORS is inner, Session can short-circuit before CORS adds headers
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(SessionMiddleware, ...)
# RIGHT — Session first (inner), CORS second (outer, wraps everything)
app.add_middleware(SessionMiddleware, ...)
app.add_middleware(CORSMiddleware, ...)This is particularly insidious because: (1) curl testing works fine since curl ignores CORS, (2) browser DevTools reports it as a CORS error not an auth error, (3) the actual response status (401) is visible in the network tab but the JS fetch API throws a TypeError, making the client think it's a network failure.