Lesson 4 - Middleware and CORS
Welcome to Middleware and CORS
So far, every piece of logic you’ve written lives inside one endpoint. But some jobs aren’t about a single route — they’re about every route. You might want to time how long each request takes, stamp a header on every response, or log who’s calling. Copy-pasting that into all your endpoints would be exactly the duplication you’ve been learning to avoid. The tool for these whole-app jobs is middleware: a layer that wraps every request on its way in and every response on its way out.
In this lesson you’ll write your own middleware, then meet a built-in one that solves a problem you’ll hit the moment a web page tries to call your API: CORS. Browsers block cross-origin requests by default, and CORS is how your API tells the browser which sites it trusts.
By the end of this lesson, you will be able to:
- Explain what middleware is and why it runs on every request
- Write custom HTTP middleware that times requests and adds response headers
- Understand why browsers block cross-origin requests, and enable specific origins with
CORSMiddleware - Reason about where middleware runs and in what order
You’ll keep building the Task Manager API. Let’s begin.
What Middleware Is
Think of a request as a package being delivered to one of your endpoints. Middleware is a checkpoint the package passes through on the way in, and again on the way out. Code in the middleware runs before your endpoint sees the request, and runs again after your endpoint produces the response — so it’s the perfect place for cross-cutting concerns: jobs that apply across many endpoints rather than to just one.
Common uses include timing requests, logging, adding a header to every response, or attaching a request ID. The key idea: you write the logic once, and it automatically applies to every route in your app.
You register HTTP middleware with the @app.middleware("http") decorator on an async def function. That function receives two things: the incoming request, and a special function called call_next. You call response = await call_next(request) to hand the request down to the rest of the app (the actual endpoint) and get back its response. Before that line, you’re on the “way in”; after it, you’re on the “way out” and can inspect or modify the response before returning it.
Here’s the smallest useful example — middleware that stamps a marker header on every response:
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_marker(request: Request, call_next):
response = await call_next(request) # run the endpoint, get its response
response.headers["X-Handled-By"] = "datatweets" # modify on the way out
return response
@app.get("/ping")
def ping():
return {"ok": True}The ping endpoint knows nothing about the header — it just returns {"ok": True}. The middleware adds X-Handled-By to the response after the endpoint runs. Let’s prove it:
from fastapi.testclient import TestClient
client = TestClient(app)
r = client.get("/ping")
print(r.json())
print(r.headers["X-Handled-By"]){'ok': True}
datatweetsThe body is exactly what the endpoint returned, but the response now carries a header the endpoint never set. Add a second endpoint tomorrow and it gets the same header for free — that’s the middleware running on every request.
A Timing Middleware
Now that you’ve seen the shape, let’s do something more practical: measure how long each request takes and report it in a header. The structure is the same — note the time before call_next, note it again after, and put the difference on the response.
import time
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_marker(request: Request, call_next):
start = time.perf_counter() # before the endpoint runs
response = await call_next(request) # run the endpoint
elapsed_ms = (time.perf_counter() - start) * 1000 # after it returns
response.headers["X-Handled-By"] = "datatweets"
response.headers["X-Process-Time"] = f"{elapsed_ms:.2f}ms"
return response
@app.get("/ping")
def ping():
return {"ok": True}time.perf_counter() gives a high-resolution timestamp in seconds. We capture it before handing off to the endpoint, capture it again after the response comes back, and the difference is how long the whole request took. Multiplying by 1000 turns seconds into milliseconds, and we format it onto the X-Process-Time header.
from fastapi.testclient import TestClient
client = TestClient(app)
r = client.get("/ping")
print(r.json())
print("X-Handled-By:", r.headers["X-Handled-By"])
print("X-Process-Time present:", "X-Process-Time" in r.headers)
print("X-Process-Time:", r.headers["X-Process-Time"]){'ok': True}
X-Handled-By: datatweets
X-Process-Time present: True
X-Process-Time: 2.73msThe exact number will vary from run to run and machine to machine — that’s the point, it’s measuring real elapsed time. What matters is that the header is present on every response without any endpoint having to think about it. This is a genuinely useful pattern: ship it and you get a timing signal on every route, which is handy for spotting slow endpoints in development.
CORS: Letting Browsers Call Your API
Here’s a problem that surprises almost everyone the first time. You build an API, you test it from Python or curl and it works perfectly. Then you load a web page from https://datatweets.com, its JavaScript tries to fetch your API, and the browser flatly refuses with a CORS error — even though the API is up and the request would have worked.
This is not a bug. It’s a browser security rule called the same-origin policy. An origin is the combination of scheme, host, and port (for example https://datatweets.com). By default, a browser will block JavaScript on one origin from reading responses from a different origin. This stops a malicious site from quietly making authenticated calls to your bank’s API in the background using your logged-in session.
CORS — Cross-Origin Resource Sharing — is the agreed-upon way to relax that rule. Your API opts in by sending a response header, access-control-allow-origin, that names which origins are allowed. When the browser sees its own origin in that header, it permits the JavaScript to read the response. FastAPI ships a ready-made middleware for this, so you don’t hand-write those headers. You add it with app.add_middleware:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://datatweets.com"], # the frontend you trust
allow_methods=["*"], # allow all HTTP methods
allow_headers=["*"], # allow all request headers
)
@app.get("/ping")
def ping():
return {"ok": True}allow_origins is the list of origins permitted to call your API from a browser — here, just your own frontend. allow_methods and allow_headers say which HTTP methods (GET, POST, …) and which request headers those origins may use; ["*"] means “all of them.” Now watch what the middleware does. A browser includes an Origin header on cross-origin requests; if it matches an allowed origin, CORS echoes it back in access-control-allow-origin:
from fastapi.testclient import TestClient
client = TestClient(app)
r = client.get("/ping", headers={"Origin": "https://datatweets.com"})
print(r.headers["access-control-allow-origin"])https://datatweets.comThat header is the browser’s green light: “this origin is allowed to read my response.” An origin you didn’t list gets no such header, and the browser blocks it. With allow_origins=["https://datatweets.com"], a request claiming Origin: https://evil.example.com comes back with no access-control-allow-origin at all, so the browser refuses to expose the response to that site. You decided exactly who’s trusted, and the middleware enforces it on every request.
CORS is a browser rule, not a firewall
CORS only constrains requests made by JavaScript running in a browser. Server-to-server calls, curl, and your Python TestClient ignore it entirely — that’s why your API “worked” before you ever hit a CORS error. So CORS is not a security wall around your data; it’s a way to tell browsers which web origins you trust. In production, list real origins explicitly in allow_origins and avoid the wildcard "*", which opens your API to every site on the web.
Where Middleware Runs and In What Order
Two practical notes will save you confusion later.
First, middleware wraps everything inside it, including your endpoints and even FastAPI’s own request handling. The code before await call_next(request) runs on the way in; the code after runs on the way out. So in the timing example, your start is captured before the endpoint and the elapsed time is measured after it returns — the middleware genuinely brackets the whole request.
Second, order matters when you have more than one middleware. Middleware added with app.add_middleware(...) wraps in reverse order of how you add it — the last one added is the outermost layer, the first to see a request and the last to touch the response. Combined with the @app.middleware("http") decorator, you can end up with several layers nested like onion skins. For now, the rule of thumb is: keep middleware focused (one job each), and if two pieces of middleware interact, remember the last-added one runs outermost.
Practice Exercises
Exercise 1: One header, every endpoint
You have a Task Manager API with five endpoints, and you want every response to carry an X-App-Version: 2.1 header. Why is middleware a better fit for this than editing each endpoint?
Hint
Setting the header is a cross-cutting concern — it applies to every route, not to any one in particular. A single @app.middleware("http") function can add response.headers["X-App-Version"] = "2.1" after call_next, and it covers all five endpoints (and any you add later) from one place. Editing each endpoint would duplicate the line five times and risk new endpoints forgetting it.
Exercise 2: Predict the CORS header
With allow_origins=["https://datatweets.com"], a browser on https://app.datatweets.com calls your API and the request includes Origin: https://app.datatweets.com. Does the response include an access-control-allow-origin header naming that origin? What does the browser do?
Hint
No. https://app.datatweets.com is a different origin from https://datatweets.com (different host), and it isn’t in allow_origins, so CORS won’t echo it back. With no matching access-control-allow-origin, the browser blocks the JavaScript from reading the response. To allow it, add https://app.datatweets.com to the allow_origins list.
Exercise 3: Why did curl work but the browser didn’t?
A teammate says: “I tested the endpoint with curl and it returned the data fine, but our web app gets a CORS error calling the same URL.” What’s going on, and where is the fix?
Hint
CORS is enforced by browsers, not by tools like curl or server-to-server clients — so curl ignores it and gets the data. The browser, however, blocks the cross-origin read because the API isn’t sending an access-control-allow-origin header for the web app’s origin. The fix is on the API: add CORSMiddleware with the web app’s origin in allow_origins.
Summary
Middleware is a layer that wraps every request and response, making it the right home for cross-cutting concerns like timing, logging, and stamping headers. You write HTTP middleware as an async def decorated with @app.middleware("http"); it receives the request and a call_next, runs response = await call_next(request) to invoke the rest of the app, and can modify the response before returning it — which is how you added an X-Handled-By marker and an X-Process-Time timing header to every response. You also met CORS: browsers block cross-origin JavaScript requests by the same-origin policy, and CORSMiddleware with allow_origins=[...] tells the browser which origins you trust by echoing an allowed Origin back in the access-control-allow-origin header. Finally, you saw that middleware brackets the whole request and that the last-added middleware runs outermost.
Key Concepts
- Middleware — code that runs on every request (in) and response (out).
@app.middleware("http")— registers a custom HTTP middleware function.call_next— runs the rest of the app and returns its response; code before it runs on the way in, code after on the way out.- CORS /
CORSMiddleware— opt browsers into cross-origin requests by listing trusted origins inallow_origins. - Same-origin policy — the browser rule CORS relaxes; it does not affect server-to-server calls.
Why This Matters
Middleware is how you apply behavior consistently across an entire API without touching individual endpoints — timing, logging, request IDs, and headers all live in one place. And CORS is not optional once a browser frontend enters the picture: nearly every real app pairs a JavaScript frontend with an API on a different origin, and without CORSMiddleware those calls simply won’t work. Knowing both means you can structure an app that’s observable, consistent, and actually reachable from the web pages that need it.
Next Steps
Continue to Lesson 5 - Guided Project: Modular App
Put structure, dependencies, and middleware together into a clean, multi-file Task Manager application.
Back to Module Overview
Return to the Structure, Dependencies, and Middleware module overview
Continue Building Your Skills
You can now run code on every request with custom middleware and open your API up to trusted browser frontends with CORS. In the next lesson you’ll bring the whole module together — folder structure, routers, dependencies, and middleware — into a single modular Task Manager app you’d be comfortable shipping.