How to Fix CORS Errors in FastAPI Without Letting Every Website Read Your Users’ Data
A step-by-step FastAPI CORS tutorial tested in Edge 155: reproduce the browser error, fix preflights, cookies and response headers, see why a wildcard origin with credentials echoes every website in...
Your front end calls your FastAPI API, and the browser console answers with a red line saying the response was blocked by CORS policy. This tutorial takes you from that message to a configuration you can defend. You will reproduce the error, fix it one setting at a time, build the two settings that quietly let any website read a signed-in user’s data, and watch a second website do exactly that in a real browser. Everything was run in Edge 155 against FastAPI 0.156.8 and Starlette 1.9.0, and the final settings are pinned by 36 tests that run in under a second.
Table Of Content
- What you will be able to do
- Four ideas to know first
- Origin
- Site
- CORS
- Simple requests, preflights and credentials
- Prerequisites
- Step 1: Build the lab
- 1a. Install the packages
- 1b. The API
- 1c. The page that calls the API
- 1d. Start three servers
- Step 2: Reproduce the error and see what the server did
- What the output teaches
- Step 3: Let your front end read the responses
- What the output teaches
- Common mistakes in this step
- Step 4: Handle preflights, custom headers and readable headers
- What the output teaches
- Preflight caching
- Step 5: Cookies and credentials
- What the output teaches
- Why the cookie’s SameSite attribute matters here
- Step 6: The two settings that let every website read your users’ data
- Wildcard plus credentials
- A regular expression that is too loose
- Step 7: What CORS does not protect
- Step 8: Errors that look like CORS errors
- A 500 error is reported as a CORS error
- A redirect on the way to the API
- Step 9: Ship a validated configuration
- Step 10: Lock it in with tests
- Step 11 (optional): Let a script drive the browser
- Quick reference: what the console says and what to change
- Confirm the whole thing works
- Where to go next
- Sources
It matters because the first answer on most forums is allow_origins=["*"], and when cookies stop working the second answer is to add allow_credentials=True. The FastAPI and Starlette docs both say that combination is not allowed, but the Starlette 1.9.0 middleware does not refuse it. It answers with whatever origin the request carried. Step 6 shows what that costs.
What you will be able to do
- Explain what an origin is, and what CORS does and does not control.
- Read the CORS messages Edge printed in this lab and name the setting that fixes each one.
- Configure
CORSMiddlewarefor a front end on one origin and an API on another, including preflights, a custom header, a readable response header and cookies. - Recognize the two configurations that let other websites read user data, and a third trap that is not a CORS bug at all: a 500 error that the browser reports as a CORS error.
- Validate allowed origins at start-up and pin the behavior with tests that fail when someone loosens it.
Four ideas to know first
Origin
An origin is the scheme, the host and the port of a URL. web.dev says that websites with “the same combination of scheme, hostname, and port” are same-origin and that everything else is cross-origin. A front end at http://localhost:3000 and an API at http://localhost:8103 are different origins even though both say localhost.
Site
A site is a looser idea: the scheme plus the registrable domain, so ports and subdomains do not matter. web.dev calls websites with “the same scheme and the same eTLD+1” same-site. Cookies use this idea through their SameSite attribute, which is why the lab has two kinds of other website.
| URL | Compared with http://localhost:3000 |
|---|---|
http://localhost:3000/notes |
same origin |
http://localhost:8103 |
cross-origin, same-site (only the port differs) |
https://localhost:3000 |
cross-origin and cross-site (the scheme differs) |
http://127.0.0.1:4000 |
cross-origin and cross-site (the host differs) |
CORS
By default a page may not read responses from another origin. CORS (cross-origin resource sharing) is how a server opts in to sharing them. MDN describes it as a mechanism that “allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources”. Notice who does the enforcing: the browser. The server only sends headers, and the browser decides whether to hand the response to your JavaScript.
Simple requests, preflights and credentials
Some cross-origin requests are sent straight away; MDN calls them simple requests. A GET, a POST with no custom headers, and a POST whose Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain are all in that group. Anything else, such as a JSON body or an Authorization header, makes the browser send an OPTIONS request first, the preflight, “in order to determine if the actual request is safe to send”. Credentials are cookies and HTTP authentication information. MDN says that in cross-origin calls “browsers will not send credentials” by default, so the page has to ask with credentials: "include".
Prerequisites
- Python 3.13 (I used 3.13.14),
pipand a terminal. The commands work in Git Bash or any POSIX shell; where PowerShell differs I show it. - A Chromium-based browser. Every browser result below was measured in Microsoft Edge 155.0.4283.62. Cookie defaults differ between browsers (MDN says that “some browsers use Lax as the default value”), so repeat the cookie steps in each browser you support.
- Enough Python to read a small FastAPI app and enough JavaScript to read a
fetch()call. You do not need to know anything about CORS yet. - Optional:
playwright, to run the automated browser check in the appendix. On the machine used here it launched the installed Edge without any extraplaywright installstep.
Step 1: Build the lab
The lab is one small API, one static page that calls it, and a few scripts. Three origins take part: the API (a different port for each CORS setting), your front end at http://localhost:3000, and “some other website” at http://127.0.0.1:4000.
1a. Install the packages
python -m venv .venv
.venv\Scripts\activate
pip install fastapi==0.156.8 uvicorn==0.54.0 httpx2==2.16.0 requests==2.34.2 pytest==9.1.1 playwright
fastapi and uvicorn are the app and its server. httpx2 is what FastAPI’s TestClient uses in this setup, pytest runs the tests, and requests and playwright are only used by the browser script in the appendix. On macOS or Linux activate with source .venv/bin/activate.
1b. The API
Save this as notes_api.py. Read it once now; every later step points at one small part.
"""notes_api.py: a small notes API for testing CORS (FastAPI 0.156.8, Starlette 1.9.0)."""
import logging
import os
from urllib.parse import urlsplit
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.datastructures import Headers
log = logging.getLogger("notes_api")
FRONTEND = "http://localhost:3000"
SESSIONS = {"alice-session": {"user": "alice", "email": "[email protected]", "api_key": "sk-alice-7f3a9c"}}
NOTES: list[dict] = []
PINGS: dict[str, int] = {}
LOG: list[dict] = []
def parse_origins(raw: str) -> list[str]:
"""Turn 'https://app.example.com,http://localhost:3000' into a checked list of origins."""
origins = []
for item in (part.strip() for part in raw.split(",")):
if not item:
continue
parts = urlsplit(item)
is_bare_origin = (
parts.scheme in ("http", "https")
and parts.hostname is not None
and parts.username is None
and item == f"{parts.scheme}://{parts.netloc}"
and item == item.lower()
)
if not is_bare_origin:
raise ValueError(f"not an origin: {item!r} (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)")
origins.append(item)
if not origins:
raise ValueError("ALLOWED_ORIGINS is empty")
return origins
METHODS = ["GET", "POST"]
HEADERS = ["Authorization"]
EXPOSE = ["X-Total-Count"]
MODES = {
"none": None,
"origin": dict(allow_origins=[FRONTEND]),
"methods": dict(allow_origins=[FRONTEND], allow_methods=METHODS, allow_headers=HEADERS, expose_headers=EXPOSE),
"credentials": dict(allow_origins=[FRONTEND], allow_methods=METHODS, allow_headers=HEADERS, expose_headers=EXPOSE, allow_credentials=True),
"star": dict(allow_origins=["*"]),
"star_credentials": dict(allow_origins=["*"], allow_credentials=True),
"nocache": dict(allow_origins=[FRONTEND], allow_methods=METHODS, allow_headers=HEADERS, max_age=0),
"loose_regex": dict(allow_origin_regex=r"https://.*example\.com", allow_credentials=True),
"strict_regex": dict(allow_origin_regex=r"https://[a-z0-9-]+\.example\.com", allow_credentials=True),
"final": dict(
allow_origins=parse_origins(os.environ.get("ALLOWED_ORIGINS", FRONTEND)),
allow_methods=METHODS, allow_headers=HEADERS, expose_headers=EXPOSE,
allow_credentials=True, max_age=600,
),
}
class NoteIn(BaseModel):
text: str
class RequestLog:
"""Lab only: remember what the server saw, including preflights that CORSMiddleware answers itself."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or scope["path"].startswith("/_debug"):
await self.app(scope, receive, send)
return
headers = Headers(scope=scope)
entry = {"method": scope["method"], "path": scope["path"], "origin": headers.get("origin"),
"cookie": "session=" in headers.get("cookie", ""), "status": None}
async def send_and_note(message):
if message["type"] == "http.response.start":
entry["status"] = message["status"]
await send(message)
try:
await self.app(scope, receive, send_and_note)
except Exception:
entry["status"] = 500
raise
finally:
LOG.append(entry)
def current_user(request: Request) -> dict | None:
token = request.cookies.get("session")
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
token = auth.removeprefix("Bearer ")
return SESSIONS.get(token)
def create_app(mode: str, catch_500: bool | None = None) -> FastAPI:
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@app.get("/login")
def login(response: Response, samesite: str = "lax"): # lab only: a GET so a browser tab can sign in
if samesite not in ("lax", "none"):
raise HTTPException(status_code=400, detail="samesite must be lax or none")
response.set_cookie("session", "alice-session", httponly=True, samesite=samesite, secure=(samesite == "none"))
return {"signed_in_as": "alice", "samesite": samesite}
@app.get("/api/me")
def me(request: Request):
user = current_user(request)
if user is None:
raise HTTPException(status_code=401, detail="not signed in")
return user
@app.get("/api/stats")
def stats(response: Response):
response.headers["X-Total-Count"] = str(len(NOTES) + 42)
return {"notes": len(NOTES), "public": True}
@app.post("/api/ping")
def ping(request: Request):
user = current_user(request)
who = user["user"] if user else "anonymous"
PINGS[who] = PINGS.get(who, 0) + 1
return {"pinged_as": who}
@app.post("/api/notes", status_code=201)
def add_note(note: NoteIn, request: Request):
user = current_user(request)
NOTES.append({"by": user["user"] if user else "anonymous", "text": note.text})
return {"saved": len(NOTES)}
@app.get("/api/items/")
def items():
return [{"id": 1}, {"id": 2}]
@app.get("/api/boom")
def boom():
raise RuntimeError("something broke inside the handler")
@app.get("/_debug/log")
def debug_log():
return {"log": LOG, "pings": PINGS, "notes": NOTES}
@app.get("/_debug/reset")
def debug_reset():
LOG.clear()
PINGS.clear()
NOTES.clear()
return {"reset": True}
if catch_500 is None:
catch_500 = mode == "final"
if catch_500:
@app.middleware("http")
async def catch_unhandled(request: Request, call_next):
try:
return await call_next(request)
except Exception:
log.exception("unhandled error on %s", request.url.path)
return JSONResponse({"detail": "internal error"}, status_code=500)
if MODES[mode] is not None:
app.add_middleware(CORSMiddleware, **MODES[mode])
app.add_middleware(RequestLog) # added last, so it is the outermost user middleware
return app
app = create_app(os.environ.get("CORS_MODE", "final"))
Five parts are worth knowing. parse_origins checks a list of origins and is used in Step 9. MODES is a dictionary of CORS settings, so one file can run with a different setting each time; the CORS_MODE environment variable picks the entry. RequestLog is lab-only: it remembers every request the server receives, including preflights that the CORS middleware answers by itself, so you can compare what the browser saw with what the server saw. create_app defines the routes: /login (a GET that sets a session cookie, which is only acceptable in a lab), /api/me (private data for the signed-in user), /api/stats (public), /api/ping and /api/notes (both change state), /api/items/, /api/boom (always fails) and two debug routes. The last lines attach the CORS middleware, and then the logger around it.
1c. The page that calls the API
The page has one button per scenario. Each scenario is a single fetch() call that a real front end might make.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CORS probe</title>
</head>
<body>
<h1>CORS probe</h1>
<p>This page is served from <code id="page-origin"></code> and calls the API at <code id="api-origin"></code>.</p>
<div id="buttons"></div>
<pre id="result">press a button, then open the DevTools console</pre>
<script src="probe.js"></script>
</body>
</html>
// probe.js: each scenario is one fetch() call that a real front end might make.
const API = new URLSearchParams(location.search).get("api") || "http://localhost:8100";
const JSON_HEADERS = { "Content-Type": "application/json" };
const BEARER = { Authorization: "Bearer alice-session" };
async function readBody(response) {
const text = await response.text();
try { return JSON.parse(text); } catch { return text.slice(0, 80); }
}
const scenarios = {
// a plain GET: no preflight
stats: async () => {
const r = await fetch(`${API}/api/stats`);
return { status: r.status, body: await readBody(r), x_total_count: r.headers.get("X-Total-Count") };
},
// a POST with no body and no custom headers is still a "simple" request: no preflight
ping_simple: async () => {
const r = await fetch(`${API}/api/ping`, { method: "POST" });
return { status: r.status, body: await readBody(r) };
},
// the same POST, but the page promises not to read the answer
ping_no_cors: async () => {
const r = await fetch(`${API}/api/ping`, { method: "POST", mode: "no-cors", credentials: "include" });
return { type: r.type, status: r.status };
},
// a JSON body makes the browser send an OPTIONS preflight first
note_json: async () => {
const r = await fetch(`${API}/api/notes`, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ text: "hello" }) });
return { status: r.status, body: await readBody(r) };
},
note_five: async () => {
const statuses = [];
for (let i = 0; i < 5; i++) {
const r = await fetch(`${API}/api/notes`, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ text: `n${i}` }) });
statuses.push(r.status);
}
return { statuses };
},
// an Authorization header is not CORS-safelisted, so this one is preflighted too
me_bearer: async () => {
const r = await fetch(`${API}/api/me`, { headers: BEARER });
return { status: r.status, body: await readBody(r) };
},
// cookies only travel cross-origin when the page asks for credentials: "include"
me_cookie: async () => {
const r = await fetch(`${API}/api/me`, { credentials: "include" });
return { status: r.status, body: await readBody(r) };
},
items: async () => {
const r = await fetch(`${API}/api/items`, { headers: BEARER });
return { status: r.status, body: await readBody(r) };
},
missing: async () => {
const r = await fetch(`${API}/api/missing`);
return { status: r.status, body: await readBody(r) };
},
boom: async () => {
const r = await fetch(`${API}/api/boom`);
return { status: r.status, body: await readBody(r) };
},
};
window.run = async (name) => {
let outcome;
try {
outcome = { ok: true, ...(await scenarios[name]()) };
} catch (error) {
outcome = { ok: false, error: `${error.name}: ${error.message}` };
}
document.getElementById("result").textContent = `${name}: ${JSON.stringify(outcome, null, 2)}`;
return outcome;
};
document.getElementById("page-origin").textContent = location.origin;
document.getElementById("api-origin").textContent = API;
for (const name of Object.keys(scenarios)) {
const button = document.createElement("button");
button.textContent = name;
button.onclick = () => window.run(name);
document.getElementById("buttons").append(button, " ");
}
Two details to notice. The stats scenario reads the X-Total-Count header, which Step 4 will make visible to the page. And window.run(name) returns the outcome as data, which is what lets a script press the buttons later.
1d. Start three servers
Use three terminals in the folder that holds notes_api.py and the site folder.
# terminal 1: the API with no CORS middleware (PowerShell: $env:CORS_MODE = "none")
CORS_MODE=none python -m uvicorn notes_api:app --port 8100
# terminal 2: your front end at http://localhost:3000
python -m http.server 3000 --bind 127.0.0.1 --directory site
# terminal 3: "some other website" at http://127.0.0.1:4000
python -m http.server 4000 --bind 127.0.0.1 --directory site
Open http://localhost:3000/probe.html?api=http://localhost:8100. You should see a row of buttons and the line “press a button, then open the DevTools console”. Open the console with F12 before you press anything, because that is where the browser explains every failure.
Step 2: Reproduce the error and see what the server did
Press stats while the API runs with no CORS middleware (mode none). To avoid screenshots, the appendix script presses the buttons in Edge for you and prints three things for each case: what the page saw, the first CORS message that Edge logged, and every request the API received. Reading all three together is the habit that makes CORS errors easy. Here are the first three cases.
browser: 155.0.4283.62
case c01 api=none page=http://localhost:3000 scenario=stats
page saw : blocked TypeError: Failed to fetch
console : No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : GET /api/stats origin=http://localhost:3000 cookie=no -> 200
case c02 api=none page=http://localhost:3000 scenario=ping_simple
page saw : blocked TypeError: Failed to fetch
console : No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : POST /api/ping origin=http://localhost:3000 cookie=no -> 200
counters : pings={'anonymous': 1}
case c03 api=none page=http://localhost:3000 scenario=note_json
page saw : blocked TypeError: Failed to fetch
console : Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : OPTIONS /api/notes origin=http://localhost:3000 cookie=no -> 405
What the output teaches
Case c01. The page’s fetch() threw TypeError: Failed to fetch, and the console says no Access-Control-Allow-Origin header is present. Now read the server line: the API received GET /api/stats and answered 200. The server did its work and sent a normal response. The browser threw the answer away. “Blocked by CORS” therefore means “your page may not read this”, not “this was never sent”.
Case c02. The same is true for a POST. The page got the same error, yet pings shows that the API counted one anonymous ping. MDN explains why browsers behave like this: the <form> element could already submit simple requests to any origin before fetch() existed, so “anyone writing a server must already be protecting against cross-site request forgery (CSRF)”. It adds that “the server still must opt-in using Access-Control-Allow-Origin to share the response with the script”. PortSwigger’s web security training is just as blunt: “CORS is not a protection against cross-origin attacks such as cross-site request forgery (CSRF).” Keep that in mind; Step 7 proves it.
Case c03. A JSON body is not a simple request, so Edge sent an OPTIONS preflight first. The route only allows POST, so FastAPI answered 405, the preflight failed, and the real POST was never sent: the server line shows only the OPTIONS. This is the situation in which the browser’s check does stop a request from running: when a preflight fails, the real request is never sent.
Step 3: Let your front end read the responses
Add CORSMiddleware and name the exact origin of your front end. In the lab this is the origin entry of MODES, and the wiring at the end of create_app attaches it.
# excerpt from notes_api.py (MODES)
"origin": dict(allow_origins=[FRONTEND]),
# excerpt from notes_api.py (end of create_app)
if MODES[mode] is not None:
app.add_middleware(CORSMiddleware, **MODES[mode])
app.add_middleware(RequestLog) # added last, so it is the outermost user middleware
Origins are compared as exact strings: scheme, host and port, with no path and no trailing slash. Restart the API with CORS_MODE=origin on port 8101 (the script does this for you) and look at the next three cases.
case c04 api=origin page=http://localhost:3000 scenario=stats
page saw : ok {"status": 200, "body": {"notes": 0, "public": true}, "x_total_count": null}
console : -
server : GET /api/stats origin=http://localhost:3000 cookie=no -> 200
case c06 api=origin page=http://127.0.0.1:4000 scenario=stats
page saw : blocked TypeError: Failed to fetch
console : No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : GET /api/stats origin=http://127.0.0.1:4000 cookie=no -> 200
case c29 api=origin page=http://localhost:3000 scenario=ping_simple
page saw : ok {"status": 200, "body": {"pinged_as": "anonymous"}}
console : -
server : POST /api/ping origin=http://localhost:3000 cookie=no -> 200
counters : pings={'anonymous': 1}
What the output teaches
Case c04. The page at http://localhost:3000 can now read the body. But x_total_count is null even though the API sets an X-Total-Count header: the browser only lets scripts read an allowlist of response headers, and the server has to add any others; MDN says that Access-Control-Expose-Headers “adds the specified headers to the allowlist”. You fix that in Step 4.
Case c06. The same request from http://127.0.0.1:4000, an origin that is not on the list, is still blocked, and again the server answered 200. A response to an unlisted origin carries no Access-Control-Allow-Origin, so the browser hides it.
Case c29. This one surprises people. A POST from the allowed origin worked and its answer was readable, although allow_methods still has its default of GET only. allow_methods is consulted when a preflight arrives, and this simple POST never needed one. Do not treat allow_methods as a firewall.
Common mistakes in this step
- Writing
http://localhost:3000/with a trailing slash. MDN notes that theOriginheader “does not include any path information, only the server name”, so the entry never matches and you get the same “no header” error as before. Step 9 adds a start-up check, and Step 10 a test, for this. - Mixing up
localhostand127.0.0.1, orhttpandhttps. They are different origins. - Forgetting the port on a development server.
Step 4: Handle preflights, custom headers and readable headers
Your real front end will send JSON and an Authorization header, so it needs preflights to succeed. Name the methods and headers it uses, and name the response headers it should be able to read. These are the methods settings.
# excerpt from notes_api.py (constants)
METHODS = ["GET", "POST"]
HEADERS = ["Authorization"]
EXPOSE = ["X-Total-Count"]
# excerpt from notes_api.py (MODES)
"methods": dict(allow_origins=[FRONTEND], allow_methods=METHODS, allow_headers=HEADERS, expose_headers=EXPOSE),
First see what a refused preflight looks like. With the origin settings (methods default to GET, no extra headers), the JSON POST and the Authorization request both fail.
case c07 api=origin page=http://localhost:3000 scenario=note_json
page saw : blocked TypeError: Failed to fetch
console : Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
server : OPTIONS /api/notes origin=http://localhost:3000 cookie=no -> 400
case c10 api=origin page=http://localhost:3000 scenario=me_bearer
page saw : blocked TypeError: Failed to fetch
console : Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
server : OPTIONS /api/me origin=http://localhost:3000 cookie=no -> 400
Both times the console says the preflight “does not have HTTP ok status”, which is true but not helpful. Starlette answers a refused preflight with status 400 and a short plain-text body that names what it refused (origin, method or headers, joined with commas). Open the OPTIONS request in the Network tab to read it; the last block of the script in Step 9 prints one such body, and the tests in Step 10 check all three. Case c07 was refused for the method (POST is not in the default list), and case c10 for the header (Authorization is not allowed).
Now the methods settings:
case c05 api=methods page=http://localhost:3000 scenario=stats
page saw : ok {"status": 200, "body": {"notes": 0, "public": true}, "x_total_count": "42"}
console : -
server : GET /api/stats origin=http://localhost:3000 cookie=no -> 200
case c08 api=methods page=http://localhost:3000 scenario=note_json
page saw : ok {"status": 201, "body": {"saved": 1}}
console : -
server : OPTIONS /api/notes origin=http://localhost:3000 cookie=no -> 200
server : POST /api/notes origin=http://localhost:3000 cookie=no -> 201
counters : notes saved=1
case c11 api=methods page=http://localhost:3000 scenario=me_bearer
page saw : ok {"status": 200, "body": {"user": "alice", "email": "[email protected]", "api_key": "sk-alice-7f3a9c"}}
console : -
server : OPTIONS /api/me origin=http://localhost:3000 cookie=no -> 200
server : GET /api/me origin=http://localhost:3000 cookie=no -> 200
What the output teaches
- c08: the server saw
OPTIONSfirst (200) and then the realPOST(201), and the page read the answer. That is a healthy preflight. - c11: the same pattern for a
GETwith anAuthorizationheader. - c05:
x_total_countis now"42", becauseexpose_headers=["X-Total-Count"]told the browser the page may read it.
Now the other side of the same mechanism, from a site you did not authorize:
case c09 api=methods page=http://127.0.0.1:4000 scenario=note_json
page saw : blocked TypeError: Failed to fetch
console : Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : OPTIONS /api/notes origin=http://127.0.0.1:4000 cookie=no -> 400
c09 is the reassuring case. The other website tried the same JSON POST, the preflight was refused for its origin, and the real request never reached the API. A request that needs a preflight is stopped before it runs unless the preflight is approved. Simple requests are not protected that way, as Step 7 shows.
Preflight caching
Every preflight is an extra round trip. Browsers cache the result for the time the server allows. Here is the same page sending five JSON POSTs to the methods settings (Starlette’s default max_age of 600 seconds), and then to a nocache variant with max_age=0.
# excerpt from notes_api.py (MODES)
"nocache": dict(allow_origins=[FRONTEND], allow_methods=METHODS, allow_headers=HEADERS, max_age=0),
case c12 api=methods page=http://localhost:3000 scenario=note_five
page saw : ok {"statuses": [201, 201, 201, 201, 201]}
console : -
server : OPTIONS /api/notes origin=http://localhost:3000 cookie=no -> 200
server : POST /api/notes origin=http://localhost:3000 cookie=no -> 201 (x5)
counters : notes saved=5
case c13 api=nocache page=http://localhost:3000 scenario=note_five
page saw : ok {"statuses": [201, 201, 201, 201, 201]}
console : -
server : OPTIONS /api/notes origin=http://localhost:3000 cookie=no -> 200 (x5)
server : POST /api/notes origin=http://localhost:3000 cookie=no -> 201 (x5)
counters : notes saved=5
With the default there was one OPTIONS request for five POSTs; with max_age=0 there were five. Keep the default, or raise it, for a busy API. MDN lists the browser caps: “Firefox caps this at 24 hours (86400 seconds)” and “Chromium (starting in v76) caps at 2 hours (7200 seconds)”, so a larger number than 7200 will not help in Edge or Chrome.
Step 5: Cookies and credentials
If your front end signs users in with a cookie, three things must line up, and the browser checks all of them.
- The page asks for credentials:
fetch(url, { credentials: "include" }). - The server answers with
Access-Control-Allow-Credentials: true, whichallow_credentials=Trueproduces. - The server names one origin in
Access-Control-Allow-Origin. MDN: “the server must specify an origin in the value of the Access-Control-Allow-Origin header, instead of specifying the * wildcard”.
The lab signs in the way a user would, by opening /login in a normal tab, so the cookie comes from a real Set-Cookie header. These are the credentials settings:
# excerpt from notes_api.py (MODES)
"credentials": dict(allow_origins=[FRONTEND], allow_methods=METHODS, allow_headers=HEADERS, expose_headers=EXPOSE, allow_credentials=True),
First the failure you get when the server forgets part 2, then the working case, then the wildcard that the browser refuses:
case c14 api=methods page=http://localhost:3000 scenario=me_cookie cookie=SameSite=lax
page saw : blocked TypeError: Failed to fetch
console : The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.
server : GET /api/me origin=http://localhost:3000 cookie=yes -> 200
case c15 api=credentials page=http://localhost:3000 scenario=me_cookie cookie=SameSite=lax
page saw : ok {"status": 200, "body": {"user": "alice", "email": "[email protected]", "api_key": "sk-alice-7f3a9c"}}
console : -
server : GET /api/me origin=http://localhost:3000 cookie=yes -> 200
case c16 api=star page=http://localhost:3000 scenario=me_cookie cookie=SameSite=lax
page saw : blocked TypeError: Failed to fetch
console : 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'.
server : GET /api/me origin=http://localhost:3000 cookie=yes -> 200
What the output teaches
c14. The cookie was sent (cookie=yes) and the server answered 200, but without Access-Control-Allow-Credentials the browser refused to give the page the response. The console message tells you exactly which header is missing.
c15. With allow_credentials=True and an explicit origin, the front end reads the signed-in user’s profile. This is the configuration you want.
c16. With allow_origins=["*"] the response says Access-Control-Allow-Origin: *, and the browser rejects it for a request that includes credentials. The FastAPI docs say the same in plain words: a wildcard allows only the kinds of communication that exclude cookies and Authorization headers. If your API sends no cookies and returns only public data, that is exactly what you want. Case c30 shows a completely unrelated website reading /api/stats under the star settings:
case c30 api=star page=http://127.0.0.1:4000 scenario=stats
page saw : ok {"status": 200, "body": {"notes": 0, "public": true}, "x_total_count": null}
console : -
server : GET /api/stats origin=http://127.0.0.1:4000 cookie=no -> 200
Why the cookie’s SameSite attribute matters here
The lab’s /login sets the cookie with an explicit SameSite value, lax or none (with Secure, which none requires: MDN says “The Secure attribute must also be set when using this value”). A Lax cookie is not sent on cross-site fetch() calls; MDN lists “requests made using the fetch() API” among the cross-site requests it excludes. A None cookie is sent on cross-site and same-site requests alike. Your front end at localhost:3000 is same-site with the API, so a Lax cookie works for it. The other website at 127.0.0.1:4000 is cross-site, so it only gets a cookie if you set SameSite=None. Edge accepted the Secure cookie over plain http on localhost, which is why the lab needs no certificate. Now see what happens when the other website asks the API with credentials and the cookie is None:
case c17 api=credentials page=http://127.0.0.1:4000 scenario=me_cookie cookie=SameSite=none
page saw : blocked TypeError: Failed to fetch
console : No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : GET /api/me origin=http://127.0.0.1:4000 cookie=yes -> 200
c17. The other website’s request carried the cookie (cookie=yes), and the API built the full profile response (200). The browser still refused to hand it over, because that origin is not on the list. This is CORS doing its job: the data left the server, and the browser kept it from the wrong page.
Step 6: The two settings that let every website read your users’ data
Wildcard plus credentials
Both documentation pages say that credentials cannot be combined with a wildcard. The FastAPI page says that none of allow_origins, allow_methods and allow_headers can be ['*'] if allow_credentials is true, and that all of them “must be explicitly specified”. The Starlette page says the same: “all of them must be explicitly specified”. Reading that, you would expect an error at start-up. There is none. Here are the settings and what the installed Starlette 1.9.0 sends:
# excerpt from notes_api.py (MODES)
"star": dict(allow_origins=["*"]),
"star_credentials": dict(allow_origins=["*"], allow_credentials=True),
This script, step_wildcard.py, asks both settings for a simple GET and a preflight from a made-up origin:
"""step_wildcard.py: what Starlette 1.9.0 sends for a wildcard, with and without allow_credentials."""
from fastapi.testclient import TestClient
from notes_api import create_app
EVIL = "https://evil.example.net"
preflight = {"Origin": EVIL, "Access-Control-Request-Method": "GET"}
for mode in ("star", "star_credentials"):
client = TestClient(create_app(mode))
simple = client.get("/api/stats", headers={"Origin": EVIL})
pre = client.options("/api/me", headers=preflight)
print(f"mode={mode}")
for label, response in (("GET ", simple), ("preflight", pre)):
print(f" {label} allow-origin={response.headers.get('access-control-allow-origin')} "
f"allow-credentials={response.headers.get('access-control-allow-credentials')}")
mode=star
GET allow-origin=* allow-credentials=None
preflight allow-origin=* allow-credentials=None
mode=star_credentials
GET allow-origin=https://evil.example.net allow-credentials=true
preflight allow-origin=https://evil.example.net allow-credentials=true
With allow_credentials=True the middleware stops answering * and answers with the origin of the request instead, whichever origin that is. The comment in starlette/middleware/cors.py says why: if credentials are allowed, “we must respond with the specific origin” instead of the wildcard. That satisfies the browser’s rule, but the origin it copies is whatever the caller claimed. Now the other website asks for the signed-in user’s profile. First with a Lax cookie, then with a None cookie:
case c18 api=star_credentials page=http://127.0.0.1:4000 scenario=me_cookie cookie=SameSite=lax
page saw : ok {"status": 401, "body": {"detail": "not signed in"}}
console : -
server : GET /api/me origin=http://127.0.0.1:4000 cookie=no -> 401
case c19 api=star_credentials page=http://127.0.0.1:4000 scenario=me_cookie cookie=SameSite=none
page saw : ok {"status": 200, "body": {"user": "alice", "email": "[email protected]", "api_key": "sk-alice-7f3a9c"}}
console : -
server : GET /api/me origin=http://127.0.0.1:4000 cookie=yes -> 200
c18. The page read a response, but only the 401. A Lax cookie was not sent on a cross-site fetch(), so the API saw an anonymous caller. The cookie’s attribute is the only thing that saved this user.
c19. With SameSite=None the cookie was sent, and the other website read the user’s email address and API key. There was no script injection and no stolen cookie: just a page the user opened, a fetch with credentials: "include", and a server that echoes any origin.
| Case | API setting | Cookie | Page origin | What the page read |
|---|---|---|---|---|
| c15 | credentials |
Lax | localhost:3000 (yours) | the profile, as intended |
| c17 | credentials |
None | 127.0.0.1:4000 (other) | nothing, blocked by the browser |
| c18 | star_credentials |
Lax | 127.0.0.1:4000 (other) | only the 401, no cookie was sent |
| c19 | star_credentials |
None | 127.0.0.1:4000 (other) | the profile, with the API key |
Two cautions before you conclude that Lax cookies make this harmless. First, the cookie in this lab says SameSite=Lax explicitly. For a cookie with no SameSite attribute MDN describes a more permissive default in some browsers, in which “cookies are also included in POST requests, as long as they were set no more than two minutes before the request was made”. Set the attribute yourself so that behavior does not depend on the browser. Second, a page on a different port or subdomain of your own site is same-site, so a Lax cookie is sent for it. If any page on a sibling subdomain can be controlled by someone else, a reflecting API is readable from there. That second point is reasoning from web.dev’s definition of same-site, not something I measured. MDN also notes that even if credentialed requests are allowed, “a browser may be configured to reject all third-party cookies in responses”, so a browser setting can change what a cross-site page receives.
A regular expression that is too loose
When you have many subdomains, allow_origin_regex is tempting. Starlette matches it against the whole origin with re.fullmatch, which helps, but a pattern that starts with .* still accepts hosts that merely end in your name. The Starlette docs warn that .* and .+ “may result in overly permissive origin matching”. Compare a loose and a strict pattern:
# excerpt from notes_api.py (MODES)
"loose_regex": dict(allow_origin_regex=r"https://.*example\.com", allow_credentials=True),
"strict_regex": dict(allow_origin_regex=r"https://[a-z0-9-]+\.example\.com", allow_credentials=True),
The script step_regex.py sends six origins to both settings:
"""step_regex.py: which origins do a loose and a strict allow_origin_regex accept? (Starlette uses re.fullmatch)"""
from fastapi.testclient import TestClient
from notes_api import MODES, create_app
ORIGINS = ["https://app.example.com", "https://evilexample.com", "https://example.com",
"https://app.example.com.evil.test", "http://app.example.com", "https://app.example.com:8443"]
loose, strict = (TestClient(create_app(mode)) for mode in ("loose_regex", "strict_regex"))
print("loose regex:", MODES["loose_regex"]["allow_origin_regex"])
print("strict regex:", MODES["strict_regex"]["allow_origin_regex"])
print(f"{'Origin header':<36}{'loose':<8}strict")
for origin in ORIGINS:
answers = [c.get("/api/stats", headers={"Origin": origin}).headers.get("access-control-allow-origin") == origin for c in (loose, strict)]
print(f"{origin:<36}{'allowed' if answers[0] else '-':<8}{'allowed' if answers[1] else '-'}")
loose regex: https://.*example\.com
strict regex: https://[a-z0-9-]+\.example\.com
Origin header loose strict
https://app.example.com allowed allowed
https://evilexample.com allowed -
https://example.com allowed -
https://app.example.com.evil.test - -
http://app.example.com - -
https://app.example.com:8443 - -
The loose pattern accepts https://evilexample.com, a name of the kind anyone could register. The strict pattern uses a character class for the subdomain label, so it accepts https://app.example.com and nothing else in this table. It also rejects the bare https://example.com; if you need the apex, add it to allow_origins as an exact string. If you can list your origins, do that instead of using a regex.
Step 7: What CORS does not protect
Two more experiments, and both use the final settings from Step 9, to show that the CORS configuration is not the thing that matters here. The other website sends a state-changing POST with mode: "no-cors". The page promises not to read the answer, and the browser sends the request anyway. First with a SameSite=None cookie, then with a Lax cookie:
case c20 api=final page=http://127.0.0.1:4000 scenario=ping_no_cors cookie=SameSite=none
page saw : ok {"type": "opaque", "status": 0}
console : -
server : POST /api/ping origin=http://127.0.0.1:4000 cookie=yes -> 200
counters : pings={'alice': 1}
case c21 api=final page=http://127.0.0.1:4000 scenario=ping_no_cors cookie=SameSite=lax
page saw : ok {"type": "opaque", "status": 0}
console : -
server : POST /api/ping origin=http://127.0.0.1:4000 cookie=no -> 200
counters : pings={'anonymous': 1}
Both responses are opaque (type: opaque, status 0), so the page learns nothing. Yet the API ran the handler. With a None cookie it ran as alice; with a Lax cookie it ran as an anonymous caller. No CORS setting could have changed either outcome. What decided it was the cookie’s SameSite attribute. The defenses against this are not in CORSMiddleware: keep session cookies on SameSite=Lax or Strict unless you truly need None, never change state on a GET, require a CSRF token (see How to Stop CSRF Attacks in a Python Web App With Synchronizer Tokens), and authorize every object on the server (see How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App).
Two more facts complete the picture. Programs that are not browsers ignore CORS entirely: curl, a Python script or an attacker’s server will call your API with any Origin header it likes, and the tests in Step 10 check that the API still answers a request that carries a foreign Origin. And CORS says nothing about who the caller is: the same tests check that /api/me still returns 401 without a session.
Step 8: Errors that look like CORS errors
Some CORS messages hide a different problem. Read the server line before you touch a setting.
A 500 error is reported as a CORS error
case c22 api=credentials page=http://localhost:3000 scenario=boom
page saw : blocked TypeError: Failed to fetch
console : No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : GET /api/boom origin=http://localhost:3000 cookie=no -> 500
Case c22 shows the console blaming a missing Access-Control-Allow-Origin while the server line says -> 500. The API crashed and the real answer was lost. The traceback that uvicorn logged for this request includes these layers, from the outside in: ServerErrorMiddleware, your middleware (the request logger, then the CORS middleware), ExceptionMiddleware and the router. An unhandled exception passes up through the CORS middleware before any response exists, and ServerErrorMiddleware, which sits outside it, builds the 500 response with no CORS headers. A normal HTTPException is turned into a response inside the stack, so it passes through the CORS middleware on the way out. That is why a 404 is readable:
case c24 api=credentials page=http://localhost:3000 scenario=missing
page saw : ok {"status": 404, "body": {"detail": "Not Found"}}
console : -
server : GET /api/missing origin=http://localhost:3000 cookie=no -> 404
The fix for crashes is to catch the exception inside the CORS middleware. add_middleware makes the last middleware you add the outermost, so the catch-all goes in first, and then the CORS middleware, as in the lab’s create_app:
# excerpt from notes_api.py (create_app)
if catch_500 is None:
catch_500 = mode == "final"
if catch_500:
@app.middleware("http")
async def catch_unhandled(request: Request, call_next):
try:
return await call_next(request)
except Exception:
log.exception("unhandled error on %s", request.url.path)
return JSONResponse({"detail": "internal error"}, status_code=500)
The final mode uses it, and the page can now read the failure:
case c23 api=final page=http://localhost:3000 scenario=boom
page saw : ok {"status": 500, "body": {"detail": "internal error"}}
console : -
server : GET /api/boom origin=http://localhost:3000 cookie=no -> 500
Log the exception, as the lab does; a catch-all that swallows errors silently trades a confusing CORS message for an invisible bug. If you want error bodies in a standard shape, How to Return RFC 9457 Problem Details From a FastAPI API covers that part.
A redirect on the way to the API
FastAPI redirects /api/items to /api/items/ because the route is declared with a trailing slash. With an Authorization header the call needs a preflight. Then a different kind of redirect: a proxy in front of the API that redirects every request, including OPTIONS.
case c25 api=methods page=http://localhost:3000 scenario=items
page saw : ok {"status": 200, "body": [{"id": 1}, {"id": 2}]}
console : -
server : OPTIONS /api/items origin=http://localhost:3000 cookie=no -> 200
server : GET /api/items origin=http://localhost:3000 cookie=no -> 307
server : OPTIONS /api/items/ origin=http://localhost:3000 cookie=no -> 200
server : GET /api/items/ origin=http://localhost:3000 cookie=no -> 200
case c26 api=final page=http://localhost:3000 scenario=me_bearer
page saw : blocked TypeError: Failed to fetch
console : Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.
server : (no request reached the API)
c25 worked, but look at the server lines: the preflight for /api/items succeeded, the GET got a 307, and the browser then sent a second preflight for /api/items/ before the final GET. A trailing-slash mismatch costs two preflights and two requests. Call the exact path.
c26 failed with “Redirect is not allowed for a preflight request”, and no request reached the API at all, because the redirecting server answered the OPTIONS request itself. MDN notes that some browsers report “Request requires preflight, which is disallowed to follow cross-origin redirects” and adds that “the CORS protocol originally required that behavior but was subsequently changed to no longer require it”, so the behavior depends on the browser. What Edge did here is the safe expectation: make the URL that the page calls the final URL, and make sure any layer in front of the API passes OPTIONS through untouched.
Step 9: Ship a validated configuration
The settings that survive all of this are the final entry: an explicit list of origins read from configuration, credentials on, explicit methods and headers, the readable header, and the default preflight cache.
# excerpt from notes_api.py (MODES)
"final": dict(
allow_origins=parse_origins(os.environ.get("ALLOWED_ORIGINS", FRONTEND)),
allow_methods=METHODS, allow_headers=HEADERS, expose_headers=EXPOSE,
allow_credentials=True, max_age=600,
),
}
The list comes from the ALLOWED_ORIGINS environment variable, through parse_origins, which refuses the mistakes this tutorial has shown:
# excerpt from notes_api.py
def parse_origins(raw: str) -> list[str]:
"""Turn 'https://app.example.com,http://localhost:3000' into a checked list of origins."""
origins = []
for item in (part.strip() for part in raw.split(",")):
if not item:
continue
parts = urlsplit(item)
is_bare_origin = (
parts.scheme in ("http", "https")
and parts.hostname is not None
and parts.username is None
and item == f"{parts.scheme}://{parts.netloc}"
and item == item.lower()
)
if not is_bare_origin:
raise ValueError(f"not an origin: {item!r} (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)")
origins.append(item)
if not origins:
raise ValueError("ALLOWED_ORIGINS is empty")
return origins
Run step_validate.py to see it work:
"""step_validate.py: parse_origins accepts a bare origin and refuses the usual mistakes at start-up."""
from notes_api import parse_origins
print(parse_origins("https://app.example.com, http://localhost:3000"))
for bad in ("*", "null", "http://localhost:3000/", "localhost:3000", "http://localhost:3000/app", "https://App.Example.com"):
try:
parse_origins(bad)
except ValueError as error:
print(f"{bad!r:<30} -> {error}")
['https://app.example.com', 'http://localhost:3000']
'*' -> not an origin: '*' (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)
'null' -> not an origin: 'null' (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)
'http://localhost:3000/' -> not an origin: 'http://localhost:3000/' (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)
'localhost:3000' -> not an origin: 'localhost:3000' (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)
'http://localhost:3000/app' -> not an origin: 'http://localhost:3000/app' (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)
'https://App.Example.com' -> not an origin: 'https://App.Example.com' (use scheme://host[:port], lower case, no path, no trailing slash, no wildcard)
A wildcard, null (PortSwigger lists sandboxed requests and pages opened from file: among the situations in which browsers may send it), a trailing slash, a missing scheme, a path and upper-case letters all stop the application at start-up with a message that says what to write instead. Failing at start-up is the point: the alternative is the silent no-match you saw in the mistakes list of Step 3.
Now see the headers that the final settings send, with no browser involved. step_headers.py asks for the same pages with a listed origin, an unlisted origin and no origin, then sends two preflights:
"""step_headers.py: print the CORS headers the final configuration sends (no browser needed)."""
from fastapi.testclient import TestClient
from notes_api import create_app
GOOD = "http://localhost:3000"
EVIL = "https://evil.example.net"
client = TestClient(create_app("final"))
WANTED = ["access-control-allow-origin", "access-control-allow-credentials", "access-control-expose-headers",
"access-control-allow-methods", "access-control-allow-headers", "access-control-max-age", "vary"]
def show(title, response):
print(f"{title} -> {response.status_code} {response.text.strip()[:40]}")
for name in WANTED:
if name in response.headers:
print(f" {name}: {response.headers[name]}")
if not any(name.startswith("access-control") for name in response.headers):
print(" (no access-control-* headers)")
show("GET /api/stats Origin: " + GOOD, client.get("/api/stats", headers={"Origin": GOOD}))
show("GET /api/stats Origin: " + EVIL, client.get("/api/stats", headers={"Origin": EVIL}))
show("GET /api/stats (no Origin)", client.get("/api/stats"))
preflight = {"Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type, authorization"}
show("OPTIONS /api/notes Origin: " + GOOD, client.options("/api/notes", headers={"Origin": GOOD, **preflight}))
show("OPTIONS /api/notes Origin: " + EVIL, client.options("/api/notes", headers={"Origin": EVIL, **preflight}))
GET /api/stats Origin: http://localhost:3000 -> 200 {"notes":0,"public":true}
access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true
access-control-expose-headers: X-Total-Count
vary: Origin
GET /api/stats Origin: https://evil.example.net -> 200 {"notes":0,"public":true}
access-control-allow-credentials: true
access-control-expose-headers: X-Total-Count
vary: Origin
GET /api/stats (no Origin) -> 200 {"notes":0,"public":true}
vary: Origin
(no access-control-* headers)
OPTIONS /api/notes Origin: http://localhost:3000 -> 200 OK
access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true
access-control-allow-methods: GET, POST
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type
access-control-max-age: 600
vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network
OPTIONS /api/notes Origin: https://evil.example.net -> 400 Disallowed CORS origin
access-control-allow-credentials: true
access-control-allow-methods: GET, POST
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type
access-control-max-age: 600
vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network
Read the second block. For the unlisted origin https://evil.example.net the API still answered 200 and the body, and it sent no Access-Control-Allow-Origin; every response also carries Vary: Origin, which MDN recommends whenever the server sends an explicit origin: “the response should also include a Vary response header with the value Origin”, because the answer can differ by origin. The last block shows the refused preflight with its plain-text reason. Then check the final settings in the browser, first for your front end and then for the other website with a SameSite=None cookie:
case c27 api=final page=http://localhost:3000 scenario=me_cookie cookie=SameSite=lax
page saw : ok {"status": 200, "body": {"user": "alice", "email": "[email protected]", "api_key": "sk-alice-7f3a9c"}}
console : -
server : GET /api/me origin=http://localhost:3000 cookie=yes -> 200
case c28 api=final page=http://127.0.0.1:4000 scenario=me_cookie cookie=SameSite=none
page saw : blocked TypeError: Failed to fetch
console : No 'Access-Control-Allow-Origin' header is present on the requested resource.
server : GET /api/me origin=http://127.0.0.1:4000 cookie=yes -> 200
Step 10: Lock it in with tests
Settings like these fail silently when someone “simplifies” them, so write the checks down. The test file below needs no browser: it uses FastAPI’s TestClient to send the same Origin and preflight headers that Edge sends.
"""test_cors.py: pytest checks for the CORS settings in notes_api.py (run: python -m pytest -q)."""
import pytest
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
import notes_api
from notes_api import create_app, parse_origins
GOOD = "http://localhost:3000"
EVIL = "https://evil.example.net"
@pytest.fixture(autouse=True)
def clean_state():
notes_api.LOG.clear()
notes_api.PINGS.clear()
notes_api.NOTES.clear()
def client(mode="final"):
return TestClient(create_app(mode), raise_server_exceptions=False)
def preflight(mode, origin, method="POST", headers="content-type, authorization"):
return client(mode).options("/api/notes", headers={
"Origin": origin, "Access-Control-Request-Method": method, "Access-Control-Request-Headers": headers})
def test_allowed_origin_is_echoed_with_credentials():
r = client().get("/api/stats", headers={"Origin": GOOD})
assert r.headers["access-control-allow-origin"] == GOOD
assert r.headers["access-control-allow-credentials"] == "true"
assert r.headers["access-control-expose-headers"] == "X-Total-Count"
assert "origin" in r.headers["vary"].lower()
@pytest.mark.parametrize("origin", [EVIL, "null", "http://localhost:3001", "https://localhost:3000", "http://127.0.0.1:3000"])
def test_final_config_never_allows_an_unlisted_origin(origin):
r = client().get("/api/stats", headers={"Origin": origin})
assert r.status_code == 200 # the server still answers; the browser is what refuses to share the answer
assert "access-control-allow-origin" not in r.headers
def test_request_without_origin_gets_no_allow_origin_but_keeps_vary():
r = client().get("/api/stats")
assert "access-control-allow-origin" not in r.headers
assert "origin" in r.headers["vary"].lower()
def test_preflight_for_the_front_end_succeeds():
r = preflight("final", GOOD)
assert r.status_code == 200
assert r.headers["access-control-allow-origin"] == GOOD
assert r.headers["access-control-allow-methods"] == "GET, POST"
assert r.headers["access-control-max-age"] == "600"
assert "authorization" in r.headers["access-control-allow-headers"].lower()
@pytest.mark.parametrize("origin, method, headers, text", [
(EVIL, "POST", "content-type", "Disallowed CORS origin"),
(GOOD, "PUT", "content-type", "Disallowed CORS method"),
(GOOD, "POST", "x-api-key", "Disallowed CORS headers"),
(EVIL, "PUT", "x-api-key", "Disallowed CORS origin, method, headers"),
])
def test_preflight_names_what_it_refuses(origin, method, headers, text):
r = preflight("final", origin, method, headers)
assert r.status_code == 400
assert r.text == text
def test_wildcard_with_credentials_reflects_any_origin():
r = client("star_credentials").get("/api/stats", headers={"Origin": EVIL})
assert r.headers["access-control-allow-origin"] == EVIL
assert r.headers["access-control-allow-credentials"] == "true"
def test_wildcard_without_credentials_stays_a_wildcard():
r = client("star").get("/api/stats", headers={"Origin": EVIL})
assert r.headers["access-control-allow-origin"] == "*"
assert "access-control-allow-credentials" not in r.headers
@pytest.mark.parametrize("origin, loose, strict", [
("https://app.example.com", True, True),
("https://evilexample.com", True, False),
("https://example.com", True, False),
("https://app.example.com.evil.test", False, False),
("http://app.example.com", False, False),
("https://app.example.com:8443", False, False),
])
def test_regex_boundaries(origin, loose, strict):
for mode, expected in (("loose_regex", loose), ("strict_regex", strict)):
allowed = client(mode).get("/api/stats", headers={"Origin": origin}).headers.get("access-control-allow-origin") == origin
assert allowed is expected, f"{mode} on {origin}"
def test_unhandled_error_has_no_cors_headers_without_a_catch_all():
r = client("credentials").get("/api/boom", headers={"Origin": GOOD})
assert r.status_code == 500
assert "access-control-allow-origin" not in r.headers
def test_unhandled_error_keeps_cors_headers_with_the_catch_all():
r = client("final").get("/api/boom", headers={"Origin": GOOD})
assert r.status_code == 500
assert r.json() == {"detail": "internal error"}
assert r.headers["access-control-allow-origin"] == GOOD
def test_cors_does_not_stop_the_request_from_running():
r = client().post("/api/ping", headers={"Origin": EVIL})
assert r.status_code == 200
assert notes_api.PINGS == {"anonymous": 1}
assert "access-control-allow-origin" not in r.headers
def test_cors_is_not_authentication():
assert client().get("/api/me", headers={"Origin": GOOD}).status_code == 401
def test_session_cookie_flags():
lax = client().get("/login").headers["set-cookie"].lower()
assert "samesite=lax" in lax and "secure" not in lax and "httponly" in lax
none = client().get("/login?samesite=none").headers["set-cookie"].lower()
assert "samesite=none" in none and "secure" in none
def test_a_trailing_slash_in_a_setting_never_matches_a_real_origin_header():
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=[GOOD + "/"])
@app.get("/")
def home():
return {"ok": True}
r = TestClient(app).get("/", headers={"Origin": GOOD})
assert r.status_code == 200
assert "access-control-allow-origin" not in r.headers
def test_parse_origins_accepts_plain_origins():
assert parse_origins("https://app.example.com, http://localhost:3000") == ["https://app.example.com", "http://localhost:3000"]
@pytest.mark.parametrize("bad", ["*", "null", "http://localhost:3000/", "localhost:3000", "http://localhost:3000/app",
"ftp://files.example.com", "https://App.Example.com", "https://[email protected]", ""])
def test_parse_origins_rejects_everything_else(bad):
with pytest.raises(ValueError):
parse_origins(bad)
Run it:
python -m pytest -q
.................................... [100%]
36 passed in 0.39s
Passing tests prove little unless they fail when the code is wrong. mutations.py copies the app and its tests to a temporary folder, breaks one thing at a time, and counts failing tests:
"""mutations.py: break the CORS settings on purpose and count the tests that notice.
Run: python mutations.py (copies notes_api.py and test_cors.py to a temp folder for each mutant)
"""
import os
import re
import shutil
import subprocess
import sys
import tempfile
LAB = os.path.dirname(os.path.abspath(__file__))
FINAL_ORIGINS = 'allow_origins=parse_origins(os.environ.get("ALLOWED_ORIGINS", FRONTEND))'
MUTANTS = [
("final forgets allow_credentials", [("allow_credentials=True, max_age=600", "max_age=600")]),
("final allows every origin", [(FINAL_ORIGINS, 'allow_origins=["*"]')]),
("final also allows the null origin", [(FINAL_ORIGINS, FINAL_ORIGINS + ' + ["null"]')]),
("parse_origins lets a wildcard through", [("if not is_bare_origin:", 'if not is_bare_origin and item != "*":')]),
("strict regex loosened to a suffix match", [(r'r"https://[a-z0-9-]+\.example\.com"', r'r"https://.*example\.com"')]),
("catch-all for unhandled errors removed", [('catch_500 = mode == "final"', "catch_500 = False")]),
("Authorization header not allowed", [('HEADERS = ["Authorization"]', "HEADERS = []")]),
]
def run_tests(folder):
env = {key: value for key, value in os.environ.items() if key not in ("CORS_MODE", "ALLOWED_ORIGINS")}
env["PYTHONDONTWRITEBYTECODE"] = "1" # two mutants of the same size, written in the same second, would share a stale .pyc
proc = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"], cwd=folder, env=env, capture_output=True, text=True)
summary = proc.stdout.strip().splitlines()[-1]
failed = re.search(r"(\d+) failed", summary)
errors = re.search(r"(\d+) error", summary)
return int(failed.group(1)) if failed else 0, int(errors.group(1)) if errors else 0, summary
def main():
with tempfile.TemporaryDirectory() as tmp:
for name in ("notes_api.py", "test_cors.py"):
shutil.copy(os.path.join(LAB, name), tmp)
base = run_tests(tmp)
print(f"{'unchanged code':<46} failed={base[0]:>2} ({base[2]})")
original = open(os.path.join(tmp, "notes_api.py"), encoding="utf-8").read()
caught = 0
for label, swaps in MUTANTS:
text = original
for old, new in swaps:
if text.count(old) != 1:
raise SystemExit(f"mutant {label!r}: expected exactly one match for {old!r}, found {text.count(old)}")
text = text.replace(old, new)
open(os.path.join(tmp, "notes_api.py"), "w", encoding="utf-8", newline="\n").write(text)
failed, errors, _ = run_tests(tmp)
caught += failed + errors > 0
print(f"{label:<46} failed={failed:>2} errors={errors}")
print(f"mutants caught: {caught} of {len(MUTANTS)}")
if __name__ == "__main__":
main()
python mutations.py
unchanged code failed= 0 (36 passed in 0.37s)
final forgets allow_credentials failed= 1 errors=0
final allows every origin failed= 8 errors=0
final also allows the null origin failed= 1 errors=0
parse_origins lets a wildcard through failed= 1 errors=0
strict regex loosened to a suffix match failed= 2 errors=0
catch-all for unhandled errors removed failed= 1 errors=0
Authorization header not allowed failed= 1 errors=0
mutants caught: 7 of 7
Every deliberate mistake is caught by at least one test: dropping allow_credentials, allowing every origin, adding null, letting a wildcard through parse_origins, loosening the regex, removing the catch-all and forgetting the Authorization header. The test that documents the hazard from Step 6 (test_wildcard_with_credentials_reflects_any_origin) deliberately passes, because it asserts what Starlette 1.9.0 does. If a future Starlette release starts refusing that combination, that test will fail and tell you.
One line in mutations.py is worth copying: it sets PYTHONDONTWRITEBYTECODE. My first version did not. Python decides whether a cached compiled file is still valid from the source file’s modification time and size, so two mutants whose files had the same size and were written in the same second could share one compiled file. The catch-all mutant printed two failures on one run and one on the next, depending on timing, because it was sometimes running the regex mutant’s code. With bytecode caching off the counts were identical on three runs.
Step 11 (optional): Let a script drive the browser
The browser output blocks above came from these two files. labkit.py starts one API per CORS setting, the two static origins and a redirecting server, and browser_check.py opens each case in a fresh browser profile, signs in when the case needs a cookie, runs the scenario and prints what the page saw, the first CORS console message and the requests the server received.
"""labkit.py: start and stop the lab servers (one API per CORS mode, two static origins, one redirector)."""
import functools
import http.server
import os
import subprocess
import sys
import threading
import time
import requests
LAB = os.path.dirname(os.path.abspath(__file__))
FRONT = "http://localhost:3000" # the real front end
ATTACKER = "http://127.0.0.1:4000" # any other site the signed-in user visits (different host, so cross-site)
REDIRECTOR = "http://localhost:8199" # stands in for a proxy that redirects everything
MODE_PORTS = {"none": 8100, "origin": 8101, "methods": 8102, "credentials": 8103, "star": 8104,
"star_credentials": 8105, "nocache": 8106, "final": 8107}
_procs: list[subprocess.Popen] = []
_servers: list[http.server.ThreadingHTTPServer] = []
def api(mode: str) -> str:
return f"http://localhost:{MODE_PORTS[mode]}"
class Quiet(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
class Redirector(http.server.BaseHTTPRequestHandler):
def go(self):
self.send_response(308)
self.send_header("Location", f"{api('final')}{self.path}")
self.send_header("Content-Length", "0")
self.end_headers()
do_GET = do_POST = do_OPTIONS = go
def log_message(self, *args):
pass
def _serve(port: int, handler) -> None:
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
_servers.append(server)
threading.Thread(target=server.serve_forever, daemon=True).start()
def start_all() -> None:
os.makedirs(os.path.join(LAB, "out"), exist_ok=True)
for mode, port in MODE_PORTS.items():
env = dict(os.environ, CORS_MODE=mode, ALLOWED_ORIGINS=FRONT)
log = open(os.path.join(LAB, "out", f"uvicorn_{mode}.log"), "w", encoding="utf-8")
_procs.append(subprocess.Popen(
[sys.executable, "-m", "uvicorn", "notes_api:app", "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=LAB, env=env, stdout=log, stderr=log))
site = functools.partial(Quiet, directory=os.path.join(LAB, "site"))
_serve(3000, site)
_serve(4000, site)
_serve(8199, Redirector)
deadline = time.time() + 60
for mode in MODE_PORTS:
while True:
try:
requests.get(f"{api(mode)}/_debug/log", timeout=2)
break
except requests.RequestException:
if time.time() > deadline:
raise SystemExit(f"API for mode {mode} did not start")
time.sleep(0.3)
def stop_all() -> None:
for server in _servers:
server.shutdown()
for proc in _procs:
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True)
"""browser_check.py: run the probe page in a real Edge and print what the page saw and what the server saw.
Usage: python browser_check.py [case ids...] (no ids = every case)
Needs: pip install playwright (it drives the Edge that is already installed; use BROWSER_CHANNEL=chrome for Chrome)
"""
import json
import os
import re
import sys
import requests
from playwright.sync_api import sync_playwright
import labkit
from labkit import ATTACKER, FRONT, REDIRECTOR, api
CASES = [
# id, API mode, page origin, scenario, cookie SameSite, extra
("c01", "none", FRONT, "stats", None, {}),
("c02", "none", FRONT, "ping_simple", None, {}),
("c03", "none", FRONT, "note_json", None, {}),
("c04", "origin", FRONT, "stats", None, {}),
("c05", "methods", FRONT, "stats", None, {}),
("c06", "origin", ATTACKER, "stats", None, {}),
("c07", "origin", FRONT, "note_json", None, {}),
("c08", "methods", FRONT, "note_json", None, {}),
("c09", "methods", ATTACKER, "note_json", None, {}),
("c10", "origin", FRONT, "me_bearer", None, {}),
("c11", "methods", FRONT, "me_bearer", None, {}),
("c12", "methods", FRONT, "note_five", None, {}),
("c13", "nocache", FRONT, "note_five", None, {}),
("c14", "methods", FRONT, "me_cookie", "lax", {}),
("c15", "credentials", FRONT, "me_cookie", "lax", {}),
("c16", "star", FRONT, "me_cookie", "lax", {}),
("c17", "credentials", ATTACKER, "me_cookie", "none", {}),
("c18", "star_credentials", ATTACKER, "me_cookie", "lax", {}),
("c19", "star_credentials", ATTACKER, "me_cookie", "none", {}),
("c20", "final", ATTACKER, "ping_no_cors", "none", {}),
("c21", "final", ATTACKER, "ping_no_cors", "lax", {}),
("c22", "credentials", FRONT, "boom", None, {}),
("c23", "final", FRONT, "boom", None, {}),
("c24", "credentials", FRONT, "missing", None, {}),
("c25", "methods", FRONT, "items", None, {}),
("c26", "final", FRONT, "me_bearer", None, {"api": REDIRECTOR}),
("c27", "final", FRONT, "me_cookie", "lax", {}),
("c28", "final", ATTACKER, "me_cookie", "none", {}),
("c29", "origin", FRONT, "ping_simple", None, {}),
("c30", "star", ATTACKER, "stats", None, {}),
]
def short(value, limit=110):
text = json.dumps(value, separators=(", ", ": ")) if not isinstance(value, str) else value
return text if len(text) <= limit else text[: limit - 3] + "..."
def cors_message(messages):
for text in messages:
match = re.search(r"CORS policy: (.*)", text)
if match:
return re.split(r"\. If an opaque response", match.group(1))[0].strip()
return None
def run_case(browser, case):
case_id, mode, page_origin, scenario, samesite, extra = case
base = api(mode)
api_param = extra.get("api", base)
context = browser.new_context()
page = context.new_page()
console = []
page.on("console", lambda msg: console.append(msg.text) if msg.type == "error" else None)
if samesite:
page.goto(f"{base}/login?samesite={samesite}") # the user signs in to the API in a normal tab
requests.get(f"{base}/_debug/reset", timeout=5)
page.goto(f"{page_origin}/probe.html?api={api_param}")
outcome = page.evaluate("name => window.run(name)", scenario)
state = requests.get(f"{base}/_debug/log", timeout=5).json()
context.close()
cookie_note = f" cookie=SameSite={samesite}" if samesite else ""
print(f"case {case_id} api={mode} page={page_origin} scenario={scenario}{cookie_note}")
if outcome["ok"]:
shown = {key: value for key, value in outcome.items() if key != "ok"}
print(f" page saw : ok {short(shown)}")
else:
print(f" page saw : blocked {outcome['error']}")
print(f" console : {cors_message(console) or '-'}")
if not state["log"]:
print(" server : (no request reached the API)")
seen = {} # the same request repeated is shown once with a count, in order of first appearance
for entry in state["log"]:
key = (entry["method"], entry["path"], entry["origin"] or "-", "yes" if entry["cookie"] else "no", entry["status"])
seen[key] = seen.get(key, 0) + 1
for (method, path, origin, cookie, status), count in seen.items():
times = f" (x{count})" if count > 1 else ""
print(f" server : {method} {path} origin={origin} cookie={cookie} -> {status}{times}")
if state["pings"]:
print(f" counters : pings={state['pings']}")
if state["notes"]:
print(f" counters : notes saved={len(state['notes'])}")
def main(wanted):
labkit.start_all()
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(channel=os.environ.get("BROWSER_CHANNEL", "msedge"), headless=True)
print(f"browser: {browser.version}")
for case in CASES:
if not wanted or case[0] in wanted:
run_case(browser, case)
browser.close()
finally:
labkit.stop_all()
if __name__ == "__main__":
main(set(sys.argv[1:]))
python browser_check.py
On a machine without Edge, set BROWSER_CHANNEL=chrome. The whole run took about two and a half minutes here. Pass case ids, such as python browser_check.py c19, to run one case.
Quick reference: what the console says and what to change
| What Edge 155 printed | What happened | What to change |
|---|---|---|
| No ‘Access-Control-Allow-Origin’ header is present on the requested resource. | The origin is not on the list, or there is no CORS middleware, or the response came from outside the middleware, such as an unhandled 500. | Compare scheme, host and port with the allowed list. Check the server log for a 500 before changing settings. |
| Response to preflight request doesn’t pass access control check: It does not have HTTP ok status. | The preflight got a 4xx. Starlette answers 400 when the origin, the method or a requested header is not allowed. | Read the plain-text body of the OPTIONS response, then add the method or the header. |
| Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. | The preflight was refused for the origin, or the route answered 405 because no CORS middleware handled OPTIONS. |
Add the origin, and make sure the middleware is installed. |
| The value of the ‘Access-Control-Allow-Credentials’ header in the response is ‘’ which must be ‘true’ when the request’s credentials mode is ‘include’. | The page sent credentials: "include" and the server did not send the credentials header. |
Set allow_credentials=True with explicit origins, or stop sending credentials. |
| 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 server answered * to a credentialed request. |
List the origins. Do not fix it by adding allow_credentials=True to a wildcard. |
| Response to preflight request doesn’t pass access control check: Redirect is not allowed for a preflight request. | Something in front of the API redirected the OPTIONS request. |
Call the final URL, and pass OPTIONS through proxies and redirect rules. |
Confirm the whole thing works
python -m pytest -qprints36 passed.python mutations.pyends withmutants caught: 7 of 7.python browser_check.pyprints 30 cases. The cases that matter most are c15 (your front end reads its profile), c19 (the wildcard-plus-credentials settings leak it), c27 (the final settings let your front end in) and c28 (the final settings keep the other website out).- Start the API with
ALLOWED_ORIGINSset to your real front end and call it from that front end in a browser. Open the Network tab and confirm that a preflight, if any, returns 200 and that the response carries your origin inAccess-Control-Allow-Origin.
Where to go next
- Add the checks to your own project: copy
parse_originsand the tests, and pointALLOWED_ORIGINSat your front end. - Repeat Steps 5 to 7 in Firefox and Safari. Their cookie defaults differ, and the cookie is what decided the outcome in this lab.
- Read about the headers a front end may read in How to Prevent Lost Updates in a FastAPI API With ETag and If-Match, where
ETagneedsexpose_headersexactly likeX-Total-Countdid here. - Look at how sessions are tracked and revoked in How to Detect and Revoke Suspicious Login Sessions in a Python Web App, and at the browser-side controls in How to Stop XSS Attacks in a Python Web App With a Content Security Policy.
Sources
- MDN, Cross-Origin Resource Sharing (CORS), for the definition, simple requests, preflights, credentialed requests and redirects.
- MDN, Access-Control-Max-Age, for the browser caps on preflight caching.
- MDN, Set-Cookie, for the
SameSitevalues and the Lax default. - web.dev, “Same-site” and “same-origin”.
- FastAPI, CORS (Cross-Origin Resource Sharing), and Starlette, Middleware.
- PortSwigger Web Security Academy, Cross-origin resource sharing (CORS).
- The installed
starlette/middleware/cors.pyfrom Starlette 1.9.0.








No Comment! Be the first one.