Module 04: Common Middleware Patterns
Why this matters
Module 03 gave you the mechanism — how a middleware wraps the request/response cycle and how the onion nests. This module gives you the cast of characters: the specific middlewares that show up in essentially every production API, and — the part beginners always underestimate — the order you have to put them in. There's a standard lineup: log the request, authenticate it, validate it, route it, and handle any errors that fall out. Security headers get stamped on the way out. Compression happens last. CORS has to be near the outside so it can answer preflight requests before anything else runs.
The reason order isn't arbitrary is that ordering bugs are silent and
dangerous. Put your rate limiter after your auth middleware, and an
attacker can hammer your expensive password-checking logic without ever being
rate-limited — you've spent CPU authenticating requests you should have
dropped at the door. Put your logging after auth, and rejected requests
never get logged, so you're blind to exactly the traffic you most want to
see. Put your error handler on the inside, and an exception in an outer
middleware sails past it and reaches the client as a raw 500 with a stack
trace. Every one of these is a real incident that has happened to real teams.
The correct order is a security and observability decision, not a style
preference.
The other theme here is that most of these are built-in or standard — you rarely hand-write CORS or compression. You configure a battle-tested component. The skill is knowing which one, what it protects against, and where it goes in the stack. This module is the reference you'll come back to every time you stand up a new service.
Concepts
Security-headers middleware
A handful of response headers instruct the browser to behave more safely. They cost nothing and prevent whole classes of attack, so you add them to every response via middleware:
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff" # don't MIME-sniff
response.headers["X-Frame-Options"] = "DENY" # no clickjacking via <iframe>
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
response.headers["Content-Security-Policy"] = "default-src 'self'"
response.headers["Referrer-Policy"] = "no-referrer"
return response
X-Content-Type-Options: nosniffstops the browser from guessing a response's content type (a vector for tricking it into executing data as script).Strict-Transport-Security(HSTS) tells the browser "only ever talk to me over HTTPS," defeating downgrade/stripping attacks. Only send it over HTTPS.Content-Security-Policy(CSP) restricts where scripts/styles/images may load from — the single most powerful anti-XSS header, and the fiddliest to tune.X-Frame-Options: DENYprevents your pages being embedded in an iframe (clickjacking).
CORS middleware
By default, browsers block a web page on https://app.example.com from
calling an API on https://api.other.com — the same-origin policy.
CORS (Cross-Origin Resource Sharing) is how your API opts in to being
called from specific other origins, by sending Access-Control-Allow-*
headers. FastAPI ships a built-in:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"], # NOT ["*"] in production with credentials
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
CORS must sit near the outside of the onion because the browser sends a
preflight OPTIONS request before the real one, and CORS middleware
answers it directly — you don't want auth or rate limiting rejecting a
preflight the browser needs to succeed. Critical footgun: allow_origins=["*"]
together with allow_credentials=True is insecure and disallowed by the spec
— never pair a wildcard origin with credentials.
CSRF-protection middleware
CSRF (Cross-Site Request Forgery) tricks a logged-in user's browser into
making a state-changing request they didn't intend, riding on their existing
cookie session. The classic defense is a per-session CSRF token that the
server issues and the client must echo back in a header on unsafe methods
(POST/PUT/PATCH/DELETE). CSRF is primarily a concern for cookie-
based auth; APIs authenticated purely by an Authorization: Bearer header
that the browser doesn't attach automatically are much less exposed. Know the
distinction: if you use cookies for auth, you need CSRF protection; if you use
bearer tokens in a header, the CSRF surface is largely gone (but you then lean
harder on CORS and correct token handling).
Rate-limiting middleware
Rate limiting caps how many requests a client may make in a window, protecting
against brute-force, scraping, and accidental floods. It returns 429 Too Many Requests (usually with a Retry-After header) when the limit is exceeded.
Position it early — before auth and before your expensive logic — so
abusive traffic is dropped before it costs you anything:
import time
from collections import defaultdict
from fastapi.responses import JSONResponse
WINDOW_SECONDS = 60
MAX_REQUESTS = 100
_hits: dict[str, list[float]] = defaultdict(list)
@app.middleware("http")
async def rate_limit(request: Request, call_next):
client = request.client.host if request.client else "unknown"
now = time.time()
_hits[client] = [t for t in _hits[client] if now - t < WINDOW_SECONDS] # drop old
if len(_hits[client]) >= MAX_REQUESTS:
return JSONResponse(status_code=429, content={"error": "rate limit exceeded"},
headers={"Retry-After": str(WINDOW_SECONDS)})
_hits[client].append(now)
return await call_next(request)
(In-memory like this only works for a single process. Real deployments use a shared store like Redis so the limit holds across many workers — track 09 goes deep. The shape and placement are what matter here.)
Authentication middleware
Auth middleware extracts and verifies a credential (a bearer token, an API
key) and either rejects unauthenticated requests or attaches the identity to
request.state for inner layers to use. It short-circuits with 401 on
failure. In FastAPI you'll often do per-route auth with dependencies
(module 05) instead, because that lets you protect some routes and leave
others public cleanly; global auth middleware is right when everything
behind it is protected. Either way, auth runs after logging and rate
limiting but before the handler.
@app.middleware("http")
async def authenticate(request: Request, call_next):
if request.url.path in PUBLIC_PATHS:
return await call_next(request)
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
user = verify_token(token) # returns None if invalid
if user is None:
return JSONResponse(status_code=401, content={"error": "unauthorized"})
request.state.user = user # hand identity to inner layers
return await call_next(request)
Logging / monitoring middleware
Structured request logging records one line per request — method, path, status, duration, request ID, client — as structured data (key/value or JSON), not a free-text sentence, so it's queryable in a log system. It sits outermost so it captures every request including those rejected by inner middleware (rate-limited, unauthorized) with their real final status.
import time, logging
logger = logging.getLogger("requests")
@app.middleware("http")
async def access_log(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
logger.info("request", extra={
"method": request.method, "path": request.url.path,
"status": response.status_code,
"duration_ms": round((time.perf_counter() - start) * 1000, 2),
"request_id": getattr(request.state, "request_id", None),
})
return response
Error-handling middleware
If a handler (or inner middleware) raises an unexpected exception, you don't
want a raw stack trace reaching the client. Error-handling middleware wraps
everything, catches exceptions, logs the detail server-side, and returns a
clean, consistent error envelope. It must be outer enough to wrap the
layers it protects (an exception in an inner layer is caught; an exception
in a layer outside the error handler is not). FastAPI also offers
@app.exception_handler(...) for typed exceptions — often cleaner than raw
try/except middleware — but the principle is the same: no stack traces to
clients, ever.
@app.middleware("http")
async def catch_errors(request: Request, call_next):
try:
return await call_next(request)
except Exception:
logger.exception("unhandled error") # full detail to server logs
return JSONResponse(status_code=500, content={"error": "internal server error"})
Compression and body-parsing middleware
Compression (GZipMiddleware) shrinks large responses so they transfer
faster; it's essentially last in line so it compresses the final body after
all other middleware has added headers. Body parsing — turning a raw
request body into usable data — is handled by FastAPI/Pydantic for JSON and
form data automatically; you configure limits and handle multipart
file uploads (UploadFile) rather than writing a parser. Know that JSON,
URL-encoded forms, and multipart (multipart/form-data, used for file
uploads) are three distinct body encodings the framework parses for you when
you declare the right parameter types.
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000) # only compress bodies > 1KB
Ordering: the canonical stack
Put it together. Reasoning outer → inner (the request enters top, exits bottom in reverse):
1. Error handling (outermost: catches everything below it)
2. Logging (records every request, even rejected ones)
3. CORS (answers preflight before deeper layers run)
4. Rate limiting (drop abusive traffic before spending CPU)
5. Authentication (verify identity before the handler)
6. Validation (Pydantic, per-route — inside the handler boundary)
7. Routing + Handler (the core)
8. Security headers / compression (applied on the way OUT)
req ─►┌────────┐─►┌────────┐─►┌──────┐─►┌──────┐─►┌──────┐─► Handler
│ error │ │logging │ │ CORS │ │ rate │ │ auth │
│handler │ │ │ │ │ │limit │ │ │
└────────┘ └────────┘ └──┬───┘ └──┬───┘ └──┬───┘
◄── security headers added on the way back out ◄────────────┘
preflight┘ 429┘ 401┘
exits short-circuit here, still logged + header-stamped on the way out
Why this order, concretely:
- Rate limiting before auth: so brute-force login attempts are throttled before they reach (and exhaust) your password-verification logic. Auth after rate limiting means an attacker can't force expensive work by spamming.
- Logging outside auth: so unauthorized/rate-limited requests are still logged — otherwise you're blind to attacks.
- Error handling outermost: so an exception anywhere below turns into a clean response, never a leaked stack trace.
- CORS near the top: so the browser's preflight
OPTIONSis answered before auth/rate-limit logic could wrongly reject it.
Order is both a performance lever (drop cheap-to-reject traffic early) and a security boundary (never do sensitive work before you've authenticated and throttled).
Command reference
| Middleware | Purpose | Registration |
|---|---|---|
| Security headers | Anti-XSS/clickjacking/downgrade headers | custom @app.middleware("http") |
CORSMiddleware |
Allow specific cross-origin browsers | app.add_middleware(CORSMiddleware, ...) |
| CSRF token check | Block forged state-changing requests (cookie auth) | custom / library |
| Rate limiting | Cap requests per client → 429 |
custom or slowapi |
| Authentication | Verify credential → 401, attach identity |
custom or per-route Depends |
| Access logging | One structured line per request | custom @app.middleware("http") |
| Error handling | Catch exceptions → clean 500 |
@app.middleware or @app.exception_handler |
GZipMiddleware |
Compress large responses | app.add_middleware(GZipMiddleware, minimum_size=) |
TrustedHostMiddleware |
Reject requests with unexpected Host |
app.add_middleware(TrustedHostMiddleware, allowed_hosts=) |
add_middleware order = reverse of onion. The middleware you
add_middleware last becomes the outermost layer. So to make error
handling outermost, add it last (or, with the decorator form, register it
last). Always verify with a trace (module 03, exercise 3) — this is the single
most error-prone thing in this module.
Prefer built-ins and vetted libraries. Don't hand-roll CORS, HSTS logic,
or production rate limiting. Use CORSMiddleware, set headers explicitly, and
reach for slowapi/Redis for real rate limiting. Hand-written versions here
are for understanding the shape, not for production.
@app.exception_handler vs. try/except middleware. For turning specific
exception types into specific responses (e.g. a NotFoundError → 404),
@app.exception_handler(NotFoundError) is cleaner and composes with
FastAPI's own handling. Use catch-all try/except middleware only as the final
safety net for truly unexpected exceptions.
Hands-on exercises
Continue in the api-layer project. Build these up and test the ordering
explicitly.
1. Add security headers to every response
Add the security_headers middleware from Concepts. Call any endpoint with
curl -i and confirm X-Content-Type-Options, X-Frame-Options,
Strict-Transport-Security, and Content-Security-Policy all appear.
Expected: every response, including a 404, carries the headers.
2. Configure CORS and see a preflight
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["*"], allow_headers=["*"],
)
Simulate a preflight:
curl -i -X OPTIONS localhost:8000/hello \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST"
Expected: a 200/204 with Access-Control-Allow-Origin: https://app.example.com. Change the Origin to something not in the list and
confirm the allow header is absent — the browser would then block the call.
3. Rate limit and get a 429
Add the in-memory rate_limit middleware from Concepts, but drop
MAX_REQUESTS to 5 for testing. Fire ten quick requests:
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/hello; done
Expected: the first five return 200, the rest 429. Confirm the 429
response carries a Retry-After header.
4. Authentication middleware with public paths
from fastapi.responses import JSONResponse
PUBLIC_PATHS = {"/health", "/docs", "/openapi.json"}
def verify_token(token: str):
return {"id": 1, "name": "ada"} if token == "secret" else None
@app.middleware("http")
async def authenticate(request: Request, call_next):
if request.url.path in PUBLIC_PATHS:
return await call_next(request)
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
user = verify_token(token)
if user is None:
return JSONResponse(status_code=401, content={"error": "unauthorized"})
request.state.user = user
return await call_next(request)
Call /hello with no token (401), with Authorization: Bearer wrong
(401), and with Authorization: Bearer secret (200). Then call /health
with no token and confirm it's allowed through.
5. Structured access logging outermost
Add the access_log middleware and register it so it's outermost. Make a
request that gets rate-limited (429) and one that's unauthorized (401).
Expected: both appear in your logs with their true status. This is the
payoff of putting logging on the outside — you see rejected traffic.
6. Error-handling middleware catches a crash
@app.get("/boom")
async def boom():
raise RuntimeError("kaboom") # simulate an unexpected bug
Add the catch_errors middleware and make it outer. Call /boom. Expected:
a clean {"error": "internal server error"} with 500, and the full
traceback in your server logs (via logger.exception) — never in the
response. Remove the middleware and confirm the difference (raw error
surfaces).
7. Compression on a large response
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=500)
@app.get("/big")
async def big():
return {"data": "x" * 5000}
Request it with and without gzip support:
curl -s -o /dev/null -w "%{size_download}\n" -H "Accept-Encoding: gzip" localhost:8000/big
curl -s -o /dev/null -w "%{size_download}\n" localhost:8000/big
Expected: the gzip request downloads far fewer bytes, and its response carries
Content-Encoding: gzip. Small responses (under 500 bytes) stay uncompressed.
8. Handle a multipart file upload
from fastapi import UploadFile
@app.post("/upload")
async def upload(file: UploadFile):
contents = await file.read()
return {"filename": file.filename, "size_bytes": len(contents),
"content_type": file.content_type}
Upload a file: curl -F "file=@README.md" localhost:8000/upload. Expected:
the JSON reports the filename, byte size, and content type. Note you declared
UploadFile and FastAPI parsed the multipart/form-data body for you — a
different encoding than JSON, handled by the framework.
9. Diagnose and fix: an ordering security bug
This stack throttles login attempts and authenticates, but a penetration test
reveals an attacker can make unlimited password-guessing attempts against
/login without ever being rate-limited, and rejected requests never appear
in the logs. The middleware functions are correct — the order is wrong.
Fix the registration so (a) rate limiting happens before auth, and (b) logging
captures every request.
app = FastAPI()
# registered in this order:
app.add_middleware(GZipMiddleware) # (1)
# ... authenticate registered here (2) ...
# ... rate_limit registered here (3) ...
# ... access_log registered here (4) ...
Solution
Recall: with add_middleware, the last registered is outermost. So the
onion above (outer → inner) is: access_log (4, outermost) → rate_limit
(3) → authenticate (2) → GZip (1, innermost). Reading that carefully, the
intended order is actually almost right — but the described symptom
("unlimited attempts, no logs") means in the buggy version rate_limit was
registered inside authenticate (so auth ran first) and access_log was
inside both. The fix is to register in the order that yields this onion,
outer → inner:
access_log (outermost) -> rate_limit -> authenticate -> handler -> GZip
Since last-registered is outermost, register them in this sequence:
app.add_middleware(GZipMiddleware, minimum_size=500) # innermost-ish (response side)
# authenticate (register 3rd from the end)
# rate_limit (register 2nd from the end)
# access_log (register LAST -> outermost)
With @app.middleware("http") decorators, the last-defined function is
outermost, so define authenticate, then rate_limit, then access_log
last. Verify with a trace: a rate-limited request should now (a) be logged and
(b) never reach authenticate. Lesson: identical middleware functions
produce completely different security properties depending purely on order —
rate limiting must be outside auth, and logging must be outside both.
Independent challenge
No code given. Assemble the full canonical stack on your api-layer app:
error handling, structured logging, CORS, rate limiting, authentication,
security headers, and compression — in the correct order. Then prove the
order is right by constructing three requests: one that is rate-limited, one
that is unauthorized, and one that triggers an unhandled exception in a
handler; and demonstrate that all three still (a) appear in your logs with the
correct final status and (b) carry your security headers on the way out.
Reuse the short-circuit concept from module 03 to reason about why a
rate-limited request never reaches auth, and reuse module 03's request-ID
middleware so every log line and every error response carries the same id.
Hint
Register from innermost to outermost bearing in mind last-registered wins:
security headers/GZip relate to the response and can be inner; error
handling and logging must be outer to see everything. Put the request-ID
middleware outermost (or just inside logging) so the id exists before any log
line is written. To prove headers apply to rejected requests, remember
short-circuit responses still travel outward through every outer middleware —
so if security headers are added on the way out by an outer layer, even a
429 gets them.
Common mistakes & troubleshooting
- Rate limiting after auth. Lets attackers exhaust expensive authentication logic. Rate limiting must be outside (before) auth.
- Logging inside auth. Rejected requests never get logged — you're blind to attacks. Logging goes outermost.
- Error handling on the inside. An exception in an outer middleware escapes it and leaks a stack trace. Put the catch-all error handler outermost.
allow_origins=["*"]withallow_credentials=True. Insecure and spec-forbidden. Name explicit origins when using credentials.- CORS too deep in the stack. The browser's preflight
OPTIONScan get rejected by auth/rate-limit before CORS answers it. Keep CORS near the top. - Hand-rolling production security middleware. Use
CORSMiddleware, vetted rate-limit libraries with a shared store, and explicit standard headers — don't invent your own. - In-memory rate limiting across multiple workers. Each process has its
own counter, so the real limit is
N × workers. Use a shared store (Redis) in production. - Sending HSTS over plain HTTP.
Strict-Transport-Securityshould only be emitted over HTTPS; sending it over HTTP is meaningless/harmful in some proxy setups.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Why must rate limiting sit before authentication in the middleware order? Give the concrete attack that the wrong order enables.
- Why does logging middleware belong on the outermost layer, and what do you lose if it's inside auth?
- What does
X-Content-Type-Options: nosniffdo, and what doesStrict-Transport-Securitydo? - Why is
allow_origins=["*"]combined withallow_credentials=Trueforbidden, and why must CORS sit near the outside of the stack? - Where must error-handling middleware sit to guarantee no stack trace ever reaches a client, and why?
- Name the three body encodings the framework parses for you, and which one is used for file uploads.
- Your in-memory rate limiter allows 100 req/min but you run 4 worker processes. What's the actual limit a single client experiences, and how do you fix it?
Answers
- So abusive traffic (e.g. brute-force login guesses) is throttled before it reaches the CPU-expensive authentication/password-verification logic. If auth runs first, an attacker can force unlimited expensive verification work by spamming — a resource-exhaustion and brute-force vector.
- So it records every request, including those rejected by inner middleware
(
429rate-limited,401unauthorized). Inside auth, rejected requests are never logged and you're blind to exactly the attack traffic you most need to see. nosniffstops the browser from guessing (MIME-sniffing) a response's content type, closing a vector where data gets executed as script. HSTS tells the browser to only ever connect over HTTPS, defeating downgrade/ SSL-stripping attacks.- The CORS spec forbids wildcard origin with credentials because it would let
any site make authenticated cross-origin requests — you must name
explicit origins. CORS sits near the outside so the browser's preflight
OPTIONSis answered before auth/rate-limiting could wrongly reject it. - Outermost (outer enough to wrap every layer that might throw). An exception in a layer outside the error handler isn't caught, so the handler must wrap everything below it; then any exception becomes a clean response instead of a leaked trace.
- JSON, URL-encoded form data, and multipart (
multipart/form-data). Multipart is used for file uploads (declared withUploadFile). - About 400 req/min (100 per process × 4 processes), because each worker has its own in-memory counter. Fix by using a shared store like Redis so the count is global across workers.
Further reading & sources
- FastAPI — CORS - configuring
CORSMiddleware, preflight handling, and the wildcard-plus-credentials footgun. - MDN — Cross-Origin Resource Sharing (CORS) - the same-origin policy, preflight
OPTIONSrequests, and theAccess-Control-Allow-*headers. - OWASP — Secure Headers Project - the reference for the security response headers (CSP, HSTS, X-Frame-Options, nosniff) this module stamps on responses.
- OWASP — Cross-Site Request Forgery (CSRF) - what CSRF is and why cookie-based auth needs token protection that bearer-token APIs largely don't.
- MDN — 429 Too Many Requests - the rate-limit status code and the
Retry-Afterheader. - Starlette — GZipMiddleware - the built-in response-compression middleware and its
minimum_sizeoption.
Next
05-request-context — auth middleware
stashed the user on request.state; now you'll formalize request-scoped
state: what belongs in it, how to share it across layers without tight
coupling, and how FastAPI's dependency injection makes it clean.