Building Custom Middleware in FastAPI to Elevate API Control
Wenhao Wang
Dev Intern · Leapcell

Introduction
In the world of backend development, building robust and scalable APIs often involves more than just defining endpoints. As your application grows, you'll encounter recurring tasks that need to be performed for almost every incoming request or outgoing response. These tasks might include authentication, logging, error handling, adding custom headers, or even modifying request/response bodies. While these functionalities can be sprinkled across individual endpoint handlers, this approach quickly leads to code duplication, decreased maintainability, and a lack of clear separation of concerns. This is precisely where the concept of middleware shines. Middleware provides a powerful, elegant, and centralized mechanism to intercept and process requests and responses at a global level, long before they reach your core application logic or after your logic has completed its work. By leveraging middleware, we can significantly enhance the control, flexibility, and architectural cleanliness of our APIs. This article will delve into the practical aspects of building custom middleware in FastAPI and Starlette, two popular Python web frameworks, to tackle these common challenges and streamline your backend development process.
Understanding Middleware in Web Frameworks
Before we dive into the implementation details, let's establish a common understanding of the core concepts involved.
What is Middleware? At its heart, middleware is a piece of software that acts as an intermediary between a request and an application's core logic, or between the application's core logic and a response. Think of it as a series of checkpoints or filters that requests (and their corresponding responses) pass through. Each piece of middleware can perform specific actions, potentially modify the request or response, and then pass it along to the next layer in the chain.
Starlette and ASGI
FastAPI is built on top of Starlette, which is a lightweight ASGI framework. ASGI (Asynchronous Server Gateway Interface) is a spiritual successor to WSGI, designed to handle asynchronous operations. Middleware in Starlette (and by extension, FastAPI) is inherently asynchronous. This means that middleware functions are async
and await
operations when dealing with the application's call.
Key Middleware Characteristics:
- Interception: Middleware can intercept both incoming requests and outgoing responses.
- Order of Execution: The order in which middleware is defined is crucial. Requests traverse the middleware chain from top to bottom, while responses traverse it from bottom to top.
- Modification: Middleware can modify request headers, body, query parameters, as well as response status codes, headers, and body.
- Short-Circuiting: Middleware can terminate the request-response cycle early, for example, by returning an error response without ever reaching the main application logic.
Building Custom Middleware
Let's explore how to create custom middleware with practical examples. We'll cover different types of common middleware use cases.
Logging Request Durations
A very common use case is to log the time taken for each request to be processed, which is invaluable for performance monitoring and debugging.
# main.py from fastapi import FastAPI, Request, Response import time import logging # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = FastAPI() @app.middleware("http") async def add_process_time_header(request: Request, call_next): start_time = time.time() response = await call_next(request) process_time = time.time() - start_time response.headers["X-Process-Time"] = str(process_time) logger.info(f"Request: {request.method} {request.url.path} processed in {process_time:.4f}s") return response @app.get("/") async def read_root(): return {"message": "Hello FastAPI!"} @app.get("/items/{item_id}") async def read_item(item_id: int): # Simulate some processing time await asyncio.sleep(0.1) return {"item_id": item_id}
In this example:
- We define an
async
functionadd_process_time_header
. - It takes
request: Request
andcall_next
as arguments.call_next
is a callable that represents the "next" operation in the middleware chain, eventually leading to your route handler. - We record the
start_time
,await call_next(request)
to pass control to the next middleware or the route handler, and then calculateprocess_time
after the response is received. - Finally, we add a custom header
X-Process-Time
to the response and log the duration.
To run this, save it as main.py
and run uvicorn main:app --reload
.
Then, make a request: curl -v http://localhost:8000/items/123
. You'll see the X-Process-Time
header in the response.
Custom Authentication Middleware
Let's imagine you have a simple token-based authentication system where a specific X-API-Key
header needs to be present and valid.
# main_auth.py from fastapi import FastAPI, Request, HTTPException, status from starlette.responses import JSONResponse app = FastAPI() SECRET_API_KEY = "supersecretkey" @app.middleware("http") async def api_key_auth_middleware(request: Request, call_next): if request.url.path.startswith("/public"): # Allow access to public paths without API key response = await call_next(request) return response api_key = request.headers.get("X-API-Key") if not api_key or api_key != SECRET_API_KEY: return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={"detail": "Unauthorized: Invalid or missing X-API-Key"} ) response = await call_next(request) return response @app.get("/protected") async def protected_route(): return {"message": "Welcome, authorized user!"} @app.get("/public") async def public_route(): return {"message": "This is a public endpoint."}
Here:
- We check for the
X-API-Key
header. - If it's missing or invalid, we immediately return a
JSONResponse
with a401 Unauthorized
status, effectively short-circuiting the request and preventing it from reaching theprotected_route
. - We also demonstrate how middleware can conditionally apply its logic, allowing public paths to bypass the authentication check.
Test with:
curl http://localhost:8000/protected
(will be Unauthorized)curl -H "X-API-Key: wrongkey" http://localhost:8000/protected
(will be Unauthorized)curl -H "X-API-Key: supersecretkey" http://localhost:8000/protected
(will be Authorized)curl http://localhost:8000/public
(will be Authorized without API key)
Modifying Request or Response Body (Advanced)
Modifying the request body can be tricky because FastAPI/Starlette's Request
object consumes the body when you access it. To modify it, you often need to read the body, modify it, recreate a new Request
object (or a mock one), and then pass it down. Modifying the response body is more straightforward.
Let's illustrate modifying a response body to add a timestamp.
# main_transform.py import json from fastapi import FastAPI, Request, Response from starlette.background import BackgroundTask app = FastAPI() @app.middleware("http") async def add_timestamp_to_response(request: Request, call_next): response = await call_next(request) # Only modify JSON responses if "application/json" in response.headers.get("content-type", ""): # Read the raw response body response_body = b"" async for chunk in response.body_iterator: response_body += chunk # Decode, modify, and re-encode try: data = json.loads(response_body) data["timestamp_utc"] = time.time() modified_body = json.dumps(data).encode("utf-8") # Create a new Response object with the modified body # And copy original status code and headers return Response( content=modified_body, status_code=response.status_code, media_type="application/json", headers=dict(response.headers), background=response.background ) except json.JSONDecodeError: # If not valid JSON, just return the original response pass return response @app.get("/data") async def get_data(): return {"value": 123, "description": "some data"}
This example is more complex:
- We iterate through
response.body_iterator
to reconstruct the original response body. This is crucial because the body is a stream and can only be read once. - We decode it, add our
timestamp_utc
field, and re-encode it. - Crucially, we return a new
Response
object with the modified content, preserving the originalstatus_code
,media_type
,headers
, andbackground
tasks. This ensures the full integrity of the original response is maintained, only the body content is altered.
With curl http://localhost:8000/data
, you'll see a timestamp_utc
field added to the JSON response.
Application Scenarios
Custom middleware can be applied in numerous scenarios:
- API Key / Token Validation: As shown, for authentication and authorization.
- Request/Response Logging: For debugging, auditing, and performance monitoring.
- Custom Header Injection: Adding correlation IDs, tracing information, or security headers.
- Error Handling: Catching specific exceptions globally and returning consistent error responses.
- Rate Limiting: Preventing abuse by limiting the number of requests from a specific IP or user.
- CORS Management: Although FastAPI provides built-in CORS middleware, you could build custom, more granular logic if needed.
- Data Transformation: Normalizing incoming request data or enriching outgoing response data.
Conclusion
Custom middleware in FastAPI and Starlette offers a powerful and flexible approach to manage cross-cutting concerns in your web applications. By centralizing common functionalities like authentication, logging, and data manipulation, you can significantly reduce code duplication, improve maintainability, and gain fine-grained control over the request-response lifecycle. Leveraging this pattern empowers you to build more robust, scalable, and architecturally clean APIs.