Module 5: Error Handling and CORS

CORS and Middleware — Cross-Origin Access

Capsule overview

Your API already handles errors properly: HTTPException raises semantic status codes, custom handlers shape the responses, and a global handler catches unexpected errors. Everything works perfectly when you test with curl or /docs. But there's one scenario where your API is unreachable: when a frontend on another domain tries to consume it.

If your API runs on http://localhost:8000 and a React frontend runs on http://localhost:3000, the browser blocks the frontend's requests to the API. It isn't a bug in your code or in the frontend — it's a browser security measure called the Same-Origin Policy. And the solution is called CORS (Cross-Origin Resource Sharing).

CORS isn't a FastAPI-specific concept. It's an HTTP standard that every browser implements. FastAPI makes the configuration easy with CORSMiddleware, but understanding what problem it solves and why it exists helps you configure it correctly — instead of just copying allow_origins=["*"] without knowing the implications.

This capsule also introduces the concept of middleware: code that runs for every request before it reaches your endpoint and after your endpoint responds. CORS is a middleware, and understanding how middlewares work prepares you for the advanced patterns you'll use in production.


Same-Origin Policy — the problem

Browsers enforce a security policy called the Same-Origin Policy: a script running on one origin (domain + protocol + port) can only make HTTP requests to that same origin.

What is an "origin"?

An origin is defined by three components:

ComponentExample
Protocolhttp:// or https://
Domainlocalhost, myapp.com
Port:3000, :8000, :443

Two URLs have the same origin only if all three components match:

URL AURL BSame origin?Why
http://localhost:3000http://localhost:3000/page✅ YesSame protocol, domain, and port
http://localhost:3000http://localhost:8000❌ NoDifferent port
http://myapp.comhttps://myapp.com❌ NoDifferent protocol
http://myapp.comhttp://api.myapp.com❌ NoDifferent domain (subdomain)
https://myapp.comhttps://myapp.com:443✅ YesPort 443 is the HTTPS default

Why does this restriction exist?

Without the Same-Origin Policy, a malicious script on malicious-site.com could make requests to your banking API while you're authenticated. The browser would send your session cookies automatically, and the malicious site could read your balance, make transfers, or steal data. The Same-Origin Policy prevents that: only scripts from the same origin as the API can make requests to it.

The problem for legitimate APIs

The Same-Origin Policy protects users, but it also blocks legitimate cases:

Frontend: http://localhost:3000  (React dev server)
API:      http://localhost:8000  (FastAPI)

The frontend tries this:

const response = await fetch("http://localhost:8000/books");
const data = await response.json();

The browser blocks the request:

Access to fetch at 'http://localhost:8000/books' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

The request never reaches your API (or the response gets discarded). It's the browser blocking it. curl doesn't have this restriction because it isn't a browser — it doesn't implement the Same-Origin Policy.


CORS — the solution

CORS (Cross-Origin Resource Sharing) is a mechanism that lets a server tell the browser which origins have permission to access its resources. It works through special HTTP headers.

How it works

  1. The browser sends a request with the header Origin: http://localhost:3000
  2. The server responds with Access-Control-Allow-Origin: http://localhost:3000
  3. The browser sees that the origin is allowed and hands the response to the JavaScript

If the server doesn't include the Access-Control-Allow-Origin header, the browser discards the response.

Preflight requests

For "complex" requests (POST with JSON, requests with custom headers, PUT, DELETE), the browser first sends a preflight request — an OPTIONS request that asks "am I allowed to make this request?":

1. Browser → Server: OPTIONS /books (preflight)
   Headers: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

2. Server → Browser: 200 OK
   Headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods,
            Access-Control-Allow-Headers

3. Browser → Server: POST /books (the real request)
   Headers: Origin, Content-Type: application/json

4. Server → Browser: 201 Created + data
   Headers: Access-Control-Allow-Origin

The preflight is automatic — the browser sends it without the JavaScript asking. Your server needs to respond correctly to OPTIONS for the real request to be sent at all.

"Simple" vs "complex" requests

TypeConditionsPreflight
SimpleGET, HEAD, or POST with Content-Type: text/plain, multipart/form-data, or application/x-www-form-urlencodedNo
ComplexPOST with application/json, PUT, PATCH, DELETE, custom headersYes

Almost every request to a REST API is "complex" because it uses Content-Type: application/json, so preflight is the norm.


CORSMiddleware in FastAPI

FastAPI ships with Starlette's CORSMiddleware. You configure it once and it applies to every endpoint:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/books")
def get_books():
    return [{"id": 1, "title": "One Hundred Years of Solitude"}]

With this configuration, a frontend on http://localhost:3000 can consume your API with no CORS errors.

CORSMiddleware parameters

ParameterTypeWhat it controlsExample
allow_originslist[str]Which origins can access it["http://localhost:3000"]
allow_methodslist[str]Which HTTP methods are allowed["GET", "POST"] or ["*"]
allow_headerslist[str]Which headers the client can send["Content-Type"] or ["*"]
allow_credentialsboolAllow cookies/auth headers cross-originTrue or False
expose_headerslist[str]Which response headers JS can read["X-Total-Count"]
max_ageintSeconds the browser caches the preflight600 (10 minutes)

Configuration for development vs production

Development — permissive

In development you want everything to work without friction:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

allow_origins=["*"] allows any origin. It's perfect for development but not for production.

Production — restrictive

In production, list only the origins that genuinely need access:

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://myapp.com",
        "https://www.myapp.com",
        "https://admin.myapp.com",
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
)

Why not use ["*"] in production?

allow_origins=["*"] says "any website on the internet can make requests to my API." That isn't a problem if your API is public and has no authentication. But if your API handles sensitive data or has authentication, ["*"] lets a malicious site send authenticated requests.

On top of that, allow_origins=["*"] and allow_credentials=True are incompatible under the CORS specification. Browsers won't send cookies if the origin is *. If you need cross-origin cookies, you have to list the origins explicitly.

The environment variables pattern

In a real project, the allowed origins come from environment variables:

import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins_str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")
origins = [origin.strip() for origin in origins_str.split(",")]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
# Development
ALLOWED_ORIGINS="http://localhost:3000,http://localhost:5173" uvicorn app.main:app --reload

# Production
ALLOWED_ORIGINS="https://myapp.com,https://www.myapp.com" uvicorn app.main:app

The middleware concept

CORS is a middleware — code that runs for every request, before and after your endpoint. A middleware is an "interceptor" in the processing pipeline:

Request from the client
    ↓
┌─────────────────┐
│ CORS middleware  │  ← Adds CORS headers, handles preflight
├─────────────────┤
│ Middleware X     │  ← Other middlewares (logging, timing, etc.)
├─────────────────┤
│  Your endpoint  │  ← Your function with the business logic
├─────────────────┤
│ Middleware X     │  ← Processes the response (in reverse order)
├─────────────────┤
│ CORS middleware  │  ← Adds CORS headers to the response
└─────────────────┘
    ↓
Response to the client

Characteristics of a middleware

  1. It runs for EVERY request — no matter which endpoint it is
  2. It can modify the request before it reaches the endpoint
  3. It can modify the response after the endpoint generates it
  4. It can stop the request before it ever reaches the endpoint (a CORS preflight, for instance)
  5. It runs in order — the first one you register is the outermost

Middleware order

If you register multiple middlewares, they run in stack order (LIFO — the last one registered runs first):

app.add_middleware(CORSMiddleware, ...)       # Runs second
app.add_middleware(OtherMiddleware, ...)       # Runs first

In practice, for this guide you only use CORSMiddleware. But the concept matters because in production you add middlewares for logging, authentication, rate limiting, compression, and more.


Verifying that CORS works

Method 1: curl with headers

curl -s -D - -o /dev/null \
  -H "Origin: http://localhost:3000" \
  http://127.0.0.1:8000/books

If CORS is configured, you'll see this in the response headers:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true

If it isn't configured, those headers won't be there.

Method 2: Preflight with OPTIONS

curl -s -D - -o /dev/null -X OPTIONS \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  http://127.0.0.1:8000/books

Expected response:

HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:3000
access-control-allow-methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT
access-control-allow-headers: Content-Type
access-control-allow-credentials: true

Method 3: From a local HTML file

Create a test_cors.html file:

<!DOCTYPE html>
<html>
<body>
    <h1>CORS Test</h1>
    <button onclick="testCors()">Test API</button>
    <pre id="result"></pre>
    <script>
    async function testCors() {
        try {
            const response = await fetch("http://localhost:8000/books");
            const data = await response.json();
            document.getElementById("result").textContent = JSON.stringify(data, null, 2);
        } catch (error) {
            document.getElementById("result").textContent = "CORS Error: " + error.message;
        }
    }
    </script>
</body>
</html>

Open this file in your browser through a local server (not with file:// — some browsers don't apply CORS to file://):

python -m http.server 3000

Open http://localhost:3000/test_cors.html and click "Test API". If you see the API's data, CORS is working.


Static files (briefly)

FastAPI can serve static files (HTML, CSS, JS, images) with StaticFiles:

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI()

app.mount("/static", StaticFiles(directory="static"), name="static")

With this, a file at static/index.html is reachable at http://localhost:8000/static/index.html.

When to use StaticFiles?

  • Serving a simple frontend from the same FastAPI app
  • Serving images, CSS, or other assets
  • Quick prototypes where you don't want a separate frontend server

For this guide, it's a brief mention. In production, static files are usually served by Nginx, a CDN, or a dedicated service — not directly from FastAPI.


CORSMiddleware with the Books API project

Here's how CORS fits into your current Books API:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="Books API", version="5.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",
        "http://localhost:5173",
        "http://localhost:5174",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ... the rest of your app (models, endpoints, etc.)

Ports 3000, 5173, and 5174 cover the most common development servers: React (3000), Vite/Vue/Svelte (5173, 5174).


Troubleshooting

Problem 1: A "CORS error" in the browser but curl works

Cause: curl doesn't implement the Same-Origin Policy — it isn't a browser. CORS only applies to requests made from JavaScript in browsers.

Solution: If curl works but the browser doesn't, the problem is CORS. Check that CORSMiddleware is configured and that the frontend's origin is in allow_origins.

Problem 2: CORS is configured but it still fails

Common cause: The origin doesn't match exactly. CORS is strict about the comparison:

# ❌ These are NOT the same origin:
"http://localhost:3000"   # with http
"https://localhost:3000"  # with https
"http://localhost:3000/"  # with a trailing slash
"http://127.0.0.1:3000"  # with an IP instead of a name

# ✅ It must match exactly what the browser sends
"http://localhost:3000"

Check the Origin header the browser sends (in DevTools → Network → Headers) and make sure it's in your list.

Problem 3: The preflight returns 405 Method Not Allowed

Cause: CORSMiddleware isn't registered, or it's registered after a middleware that rejects OPTIONS.

Solution: CORSMiddleware should be among the first middlewares you register:

app = FastAPI()

# ✅ Register CORS first
app.add_middleware(CORSMiddleware, ...)
# Other middlewares after

Problem 4: Cookies aren't sent cross-origin

Cause: allow_credentials=False, or allow_origins=["*"] together with credentials.

# ❌ Doesn't work with cookies
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)

# ✅ Explicit origins + credentials
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
)

Problem 5: Custom headers aren't visible in JavaScript

Cause: By default, JavaScript can only read a limited set of response headers. For custom headers, use expose_headers:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    expose_headers=["X-Total-Count", "X-Request-Id"],
)

Exercises

Exercise 1: Configure basic CORS (Easy)

Create a FastAPI app with CORSMiddleware that allows requests from http://localhost:3000. Add a GET /status endpoint that returns {"status": "ok"}. Use curl to verify that the Access-Control-Allow-Origin header shows up.

See solution
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/status")
def get_status():
    return {"status": "ok"}
curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" http://127.0.0.1:8000/status

Expected output (among the headers):

access-control-allow-origin: http://localhost:3000
access-control-allow-credentials: true

Exercise 2: Verify the preflight (Easy)

Using the app from the previous exercise, simulate a preflight request with curl -X OPTIONS. Verify that the server responds with the correct CORS headers for a POST with JSON.

See solution
curl -s -D - -o /dev/null -X OPTIONS \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  http://127.0.0.1:8000/status

Expected output:

HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:3000
access-control-allow-methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT
access-control-allow-headers: Content-Type
access-control-allow-credentials: true

The server responds 200 to the OPTIONS with the headers that authorize the real request.

Exercise 3: Restrictive CORS for production (Medium)

Configure CORSMiddleware for production: only 2 specific origins, only the GET and POST methods, only the Content-Type and Authorization headers. Test that an origin that isn't on the list gets rejected.

See solution
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://myapp.com",
        "https://admin.myapp.com",
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Content-Type", "Authorization"],
)


@app.get("/data")
def get_data():
    return {"message": "Production data"}
# ✅ An allowed origin
curl -s -D - -o /dev/null \
  -H "Origin: https://myapp.com" \
  http://127.0.0.1:8000/data
# access-control-allow-origin: https://myapp.com

# ❌ An origin that isn't allowed
curl -s -D - -o /dev/null \
  -H "Origin: http://localhost:3000" \
  http://127.0.0.1:8000/data
# No access-control-allow-origin header → the browser would block it

The server doesn't return the Access-Control-Allow-Origin header for origins that aren't on the list.

Exercise 4: CORS with environment variables (Medium)

Configure the CORS origins from an ALLOWED_ORIGINS environment variable (comma-separated). If the variable doesn't exist, use http://localhost:3000 as the default.

See solution
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins_str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")
origins = [origin.strip() for origin in origins_str.split(",")]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/config")
def get_config():
    return {"allowed_origins": origins}
# No variable → uses the default
uvicorn app.main:app --reload
curl -s http://127.0.0.1:8000/config
# {"allowed_origins":["http://localhost:3000"]}

# With the variable → uses the origins you specified
ALLOWED_ORIGINS="https://myapp.com,https://admin.myapp.com" uvicorn app.main:app
curl -s http://127.0.0.1:8000/config
# {"allowed_origins":["https://myapp.com","https://admin.myapp.com"]}

Exercise 5: A complete app with CORS + error handling (Hard)

Build an app with: CORSMiddleware, a GET endpoint with a 404, a POST endpoint with a 409 for duplicates, and a RequestValidationError handler. Use curl to verify that both the successful responses and the error responses include CORS headers.

See solution
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    errors = [{"field": " → ".join(str(l) for l in e["loc"]), "message": e["msg"]}
              for e in exc.errors()]
    return JSONResponse(
        status_code=422,
        content={"status": "error", "code": "VALIDATION_ERROR", "errors": errors}
    )


items = [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}]


class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)


@app.get("/items/{item_id}")
def get_item(item_id: int):
    item = next((i for i in items if i["id"] == item_id), None)
    if item is None:
        raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
    return item


@app.post("/items", status_code=201)
def create_item(item: ItemCreate):
    for existing in items:
        if existing["name"].lower() == item.name.lower():
            raise HTTPException(status_code=409, detail=f"Item '{item.name}' already exists")
    new_item = {"id": len(items) + 1, "name": item.name}
    items.append(new_item)
    return new_item
# ✅ Success with CORS headers
curl -s -D - -H "Origin: http://localhost:3000" http://127.0.0.1:8000/items/1
# access-control-allow-origin: http://localhost:3000
# {"id":1,"name":"Widget"}

# ❌ A 404 error with CORS headers
curl -s -D - -H "Origin: http://localhost:3000" http://127.0.0.1:8000/items/99
# access-control-allow-origin: http://localhost:3000
# {"detail":"Item 99 not found"}

# ❌ A 422 error with CORS headers
curl -s -D - -H "Origin: http://localhost:3000" \
  -X POST http://127.0.0.1:8000/items \
  -H "Content-Type: application/json" \
  -d '{"name": ""}'
# access-control-allow-origin: http://localhost:3000
# {"status":"error","code":"VALIDATION_ERROR","errors":[...]}

The CORS headers show up in every response — success and error alike — because the middleware runs for every request.

Exercise 6: Test CORS from HTML (Hard)

Create a test_cors.html file that performs 3 operations against your API: GET the list, POST a new item, GET an item by ID. Show the results on the page. Serve the HTML with python -m http.server 3000 and verify that everything works with no CORS errors.

See solution
<!DOCTYPE html>
<html>
<head><title>CORS Test</title></head>
<body>
    <h1>CORS Test</h1>
    <button onclick="listItems()">GET /items</button>
    <button onclick="createItem()">POST /items</button>
    <button onclick="getItem(1)">GET /items/1</button>
    <pre id="result" style="background: #f0f0f0; padding: 16px; margin-top: 16px;"></pre>

    <script>
    const API = "http://localhost:8000";
    const result = document.getElementById("result");

    async function listItems() {
        const res = await fetch(`${API}/items`);
        const data = await res.json();
        result.textContent = `GET /items (${res.status}):\n` + JSON.stringify(data, null, 2);
    }

    async function createItem() {
        const res = await fetch(`${API}/items`, {
            method: "POST",
            headers: {"Content-Type": "application/json"},
            body: JSON.stringify({name: "New Item " + Date.now()})
        });
        const data = await res.json();
        result.textContent = `POST /items (${res.status}):\n` + JSON.stringify(data, null, 2);
    }

    async function getItem(id) {
        const res = await fetch(`${API}/items/${id}`);
        const data = await res.json();
        result.textContent = `GET /items/${id} (${res.status}):\n` + JSON.stringify(data, null, 2);
    }
    </script>
</body>
</html>
# Terminal 1: the API
uvicorn app.main:app --reload

# Terminal 2: the static server
python -m http.server 3000

Open http://localhost:3000/test_cors.html. Click each button. If you see data in the <pre> with no errors in the browser console, CORS is working.


Summary

  • The Same-Origin Policy is a browser security measure that blocks cross-origin requests
  • An origin is defined by protocol + domain + port — all three have to match
  • CORS lets a server authorize requests from other origins via HTTP headers
  • Preflight (OPTIONS) is an automatic browser request sent before complex requests
  • CORSMiddleware is configured with app.add_middleware()allow_origins=["*"] is for development only
  • Middleware is code that intercepts every request and response in the pipeline
  • The pipeline is: request → middlewares → endpoint → middlewares → response
  • StaticFiles serves static files — handy for prototypes; in production use Nginx or a CDN

Additional resources

  1. FastAPI - CORS - Official tutorial on CORSMiddleware in FastAPI
  2. MDN - Cross-Origin Resource Sharing - A full explanation of the CORS standard
  3. MDN - Same-Origin Policy - The security policy that CORS relaxes
  4. FastAPI - Middleware - The general middleware concept in FastAPI
  5. FastAPI - Static Files - Serving static files with StaticFiles
  6. Starlette - CORSMiddleware - Documentation for the underlying implementation