TRENDING
An out-of-order sign standing in an airport sink basin, with a title, a friendly explanation and a Spanish translation, like the members of an error response
October 21, 2026
How to Return RFC 9457 Problem Details From a FastAPI API and Stop Error Responses From Leaking Secrets
Heavily corroded cogwheels and a rusted ring gear inside an abandoned sulphur factory beside the sea on White Island
October 21, 2026
Cloudflare’s tsrs Turns an Agent-Written Compiler Port Into a Differential-Testing Bet, and the 8x Headline Depends on the Baseline
A black public warning siren with a wide dish-shaped horn on a short pole against a clear blue sky
October 21, 2026
PHP Warns of Two Critical Flaws and Sets a 14:00 UTC Patch, but the Leak, the Score and the Exploitation Reports Do Not Agree
Rows of red climbing helmets, carabiners and harness straps laid out on the ground at a ropes course
October 21, 2026
How to Build a Safe AI Agent Harness in Python With Tool Validation, a File Sandbox, and Run Limits
A thin transparent polymer test specimen stretched between the metal grips of a tensile testing machine
October 21, 2026
Ai2’s FairStress Turns Fairness Testing Into a Pressure Test, but the Released Files Do Not Match the Report
21 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Blue velvet rope strung between chrome posts across the entrance of a glass storefront, a picture of an allow-list at the door
How to Fix CORS Errors in FastAPI Without Letting Every Website Read Your Users’ Data
October 21, 2026
A walnut kernel standing upright between the two halves of its cracked shell, a visual pun on the Linux kernel and its shell
Infinity Core Turns Six Kernel Bugs Into a Patch-Pipeline Test, and Five Days After the Exploit Code Ubuntu and Amazon Linux Still Had No Fix for the Timer Bug
October 21, 2026
An African elephant facing the camera in tall grass, a nod to PHP’s elephant mascot
PHP Ships the PHP2Shell Fixes a Day Early After a Leak, and Attackers Were Probing Before the Patch Landed
October 21, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 256 Posts
News 259 Posts
Learning Hub 229 Posts
Home/Learning Hub/How to Fix CORS Errors in FastAPI Without Letting Every Website Read Your Users’ Data
Learning Hub

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

October 21, 2026 37 Min Read
5

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 CORSMiddleware for 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), pip and 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 extra playwright install step.

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 the Origin header “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 localhost and 127.0.0.1, or http and https. 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 OPTIONS first (200) and then the real POST (201), and the page read the answer. That is a healthy preflight.
  • c11: the same pattern for a GET with an Authorization header.
  • c05: x_total_count is now "42", because expose_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.

  1. The page asks for credentials: fetch(url, { credentials: "include" }).
  2. The server answers with Access-Control-Allow-Credentials: true, which allow_credentials=True produces.
  3. 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

  1. python -m pytest -q prints 36 passed.
  2. python mutations.py ends with mutants caught: 7 of 7.
  3. python browser_check.py prints 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).
  4. Start the API with ALLOWED_ORIGINS set 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 in Access-Control-Allow-Origin.

Where to go next

  • Add the checks to your own project: copy parse_origins and the tests, and point ALLOWED_ORIGINS at 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 ETag needs expose_headers exactly like X-Total-Count did 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 SameSite values 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.py from Starlette 1.9.0.

Tags:

Browser SecurityCORSFastAPIpytestPythonWeb Security

Share

A walnut kernel standing upright between the two halves of its cracked shell, a visual pun on the Linux kernel and its shell
Previous Post

Infinity Core Turns Six Kernel Bugs Into a Patch-Pipeline Test, and Five Days After the Exploit Code Ubuntu and Amazon Linux Still Had No Fix for the Timer Bug

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
21 Oct
How to Fix CORS Errors in FastAPI Without Letting Every Website Read Your Users’ Data
21 Oct
Infinity Core Turns Six Kernel Bugs Into a Patch-Pipeline Test, and Five Days After the Exploit Code Ubuntu and Amazon Linux Still Had No Fix for the Timer Bug
Trending
October 21, 2026
How to Fix CORS Errors in FastAPI Without Letting Every Website Read Your Users’ Data
October 21, 2026
Infinity Core Turns Six Kernel Bugs Into a Patch-Pipeline Test, and Five Days After the Exploit Code Ubuntu and Amazon Linux Still Had No Fix for the Timer Bug
October 21, 2026
PHP Ships the PHP2Shell Fixes a Day Early After a Leak, and Attackers Were Probing Before the Patch Landed
October 21, 2026
How to Return RFC 9457 Problem Details From a FastAPI API and Stop Error Responses From Leaking Secrets
October 21, 2026
Cloudflare’s tsrs Turns an Agent-Written Compiler Port Into a Differential-Testing Bet, and the 8x Headline Depends on the Baseline
October 21, 2026
PHP Warns of Two Critical Flaws and Sets a 14:00 UTC Patch, but the Leak, the Score and the Exploitation Reports Do Not Agree

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026