Python Routing Middleware on Vercel: FastAPI, Flask, Django Integration Guide

Vercel's latest Python release added routing middleware through the vercel-proxy package: code that runs before your app and can route, redirect and change request headers. If you run FastAPI, Flask or Django on Vercel, the useful question is not how to turn it on. It is which of your existing checks move out of the app, and which must stay inside it. Vercel Python middleware and FastAPI middleware share a name, not a layer, and treating them as one stack is how a redirect rule turns into an auth hole.
TL;DR
- Routing middleware runs before the app. Framework middleware runs inside it. Vercel's example redirects with HTTP 308, which is the right status only for URLs that moved for good.
- Keep every redirect decision in one pure Python function, then let each framework enforce sessions its own way: a dependency in FastAPI,
before_requestin Flask, a middleware class in Django. - Never let the app trust a header because the routing layer set it. OWASP LLM06 asks for independent downstream authorization, and the tests below prove you have it.
By the end you will have a shared rules module, a working session check for all three frameworks, a slash handling plan, and a test file you can run in CI. If this Python service is the backend for an AI agent, the choice of agent stack is a separate decision, and the AI agent framework chooser helps with that one. Everything here applies either way.
Separate routing and application layers: a Vercel Python middleware FastAPI guide
The split that holds up in production is simple. The routing layer answers questions it can settle from the URL and the presence of a cookie. The application layer answers questions that need a verified identity or your database.
| Job | Routing layer | Application layer |
|---|---|---|
| Moved URL map | Yes | No |
| Send signed out users to login | Yes, by cookie presence only | Also enforces it |
| Verify a session signature | No | Yes |
| Decide what a user can do | No | Yes, on every request |
| Add a request ID to the response | No | Yes |
FastAPI's documentation shows the in app shape: @app.middleware("http") wraps each request and response, in a sample that targets Python 3.10+. That wrapper lives in your process. Don't assume it shares an ordering, a runtime or a config file with the routing layer.
Here is the module both layers can rely on. It has no framework imports and no Vercel imports, so it survives any change to either.
# routing_rules.py
from urllib.parse import quote, urlsplit
PERMANENT_MOVES = {
"/docs/v1": "/docs",
"/api/v1/reports": "/api/reports",
}
LOGIN_GATED_PREFIXES = ("/dashboard", "/settings")
def is_safe_next(target: str) -> bool:
parts = urlsplit(target)
return (
target.startswith("/")
and not target.startswith("//")
and "\\" not in target
and not parts.scheme
and not parts.netloc
)
def decide(path: str, has_session_cookie: bool) -> tuple[int, str] | None:
"""Return (status, location) for a redirect, or None to pass through."""
if path in PERMANENT_MOVES:
return 308, PERMANENT_MOVES[path]
if path.startswith(LOGIN_GATED_PREFIXES) and not has_session_cookie:
next_path = path if is_safe_next(path) else "/"
return 307, f"/login?next={quote(next_path)}"
return None
Call decide from a thin handler wired the way Vercel's changelog and docs show. I am leaving that handler out on purpose: the feature is new, and a signature pasted from memory is worse than none. All your logic is in the function above, which you can test without a deployment.
Configure ASGI/WSGI entrypoints for each framework
The three frameworks differ in where the app object lives and where a per request check belongs. FastAPI is ASGI. Flask is WSGI. Django ships both an asgi.py and a wsgi.py, so pick one and stay with it. Check Vercel's Python docs for the entrypoint file names your project should use.
All three use one verifier. It takes a signed token and returns a user ID or None, and it ignores every header.
# sessions.py
import hashlib
import hmac
import os
SECRET = os.environ.get("SESSION_SECRET", "local-test-secret").encode()
def sign(user_id: str) -> str:
sig = hmac.new(SECRET, user_id.encode(), hashlib.sha256).hexdigest()
return f"{user_id}.{sig}"
def verify_session(token: str | None) -> str | None:
if not token or "." not in token:
return None
user_id, _, sig = token.rpartition(".")
expected = hmac.new(SECRET, user_id.encode(), hashlib.sha256).hexdigest()
return user_id if hmac.compare_digest(sig, expected) else None
This is a teaching verifier with no expiry. In production, use your auth library's session check and keep the same function shape.
FastAPI: a dependency, not middleware
Use @app.middleware("http") for cross cutting work like request IDs. Put identity in a dependency, so a route that needs a user declares it and can't forget it.
# fastapi_app.py
import uuid
from fastapi import Depends, FastAPI, HTTPException, Request
from sessions import verify_session
app = FastAPI()
@app.middleware("http")
async def add_request_id(request: Request, call_next):
response = await call_next(request)
response.headers["X-Request-ID"] = uuid.uuid4().hex
return response
def current_user(request: Request) -> str:
user = verify_session(request.cookies.get("session"))
if user is None:
raise HTTPException(status_code=401, detail="Not signed in")
return user
@app.get("/dashboard/billing")
def billing(user: str = Depends(current_user)) -> dict[str, str]:
return {"user": user}
Flask: before_request
# flask_app.py
from flask import Flask, abort, g, jsonify, request
from routing_rules import LOGIN_GATED_PREFIXES
from sessions import verify_session
app = Flask(__name__)
@app.before_request
def require_session():
if request.path.startswith(LOGIN_GATED_PREFIXES):
g.user = verify_session(request.cookies.get("session"))
if g.user is None:
abort(401)
@app.get("/dashboard/billing")
def billing():
return jsonify(user=g.user)
Django: a middleware class
Add it to MIDDLEWARE in your settings, after CommonMiddleware.
# django_auth.py
from django.http import JsonResponse
from routing_rules import LOGIN_GATED_PREFIXES
from sessions import verify_session
class VerifiedSessionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path.startswith(LOGIN_GATED_PREFIXES):
request.verified_user = verify_session(request.COOKIES.get("session"))
if request.verified_user is None:
return JsonResponse({"detail": "Not signed in"}, status=401)
return self.get_response(request)
Notice what none of these read: X-User-Id. Header changes are one of the three things routing middleware can do, so it is tempting to look up the user there and pass the result along. Don't. A client can send any header it likes, and your authorization would rest on one. OWASP's excessive agency entry makes the same point for agent tools: limit what is exposed and enforce authorization downstream, not through model prompts. If a verified user later decides what a tool can do, our guide to prompt injection and agent security covers why that boundary has to stand alone.
Middleware deployment dependencies
A repo that now holds routing rules and an app has two deployable concerns, and they can drift. Four habits keep them honest:
- Import, don't copy. Both layers import
routing_rules.py. If the prefix list lives in two files, one will go stale. - Keep the routing path light. Routing middleware runs before the app on matching requests, so don't import your ORM, your framework or an LLM client into it. Standard library plus
vercel-proxyis the target. - Pin
vercel-proxy. It is a young package. Pin it in your lockfile and read the changelog before you bump it. - Fail open on routing, closed on auth. If the routing layer throws, the request should still reach an app that enforces identity. The reverse is never acceptable.
Habit four is why the framework checks above return 401 by themselves. My opinion: if deleting your routing middleware would expose a single private route, the design is wrong, because that layer is an optimization and not a control.
Correct path and redirect handling
Vercel's published example uses HTTP 308. Under RFC 9110, 308 is permanent, keeps the method and body, and can be cached. So a browser can keep following an old rule after you deploy a new one, and your deploy can't reach its cache. That is ideal for /api/v1/reports, because a POST stays a POST. It is a poor fit for a login gate, which is why decide returns 307 there.
The quiet bug here is the trailing slash, because all three frameworks have their own opinion and they don't agree. Ask for /items when only /items/ is routed and FastAPI answers 307, Flask answers 308 and Django's CommonMiddleware answers 301 (confirmed by running them on Django 6.1, Flask 3.1 and FastAPI 0.162, and worth pinning in a test because defaults can change). A 301 can also turn a POST into a GET in some clients. Stack a routing rule that strips slashes on top of a framework that adds them, and you have a redirect loop that only appears after deployment.
Pick one canonical form and let one layer own it:
- Decide on no trailing slash, or a trailing slash, for the whole site.
- Normalize in the routing layer only if you also turn the framework's behaviour off:
redirect_slashes=Falsein FastAPI,strict_slashes=Falsein Flask,APPEND_SLASH = Falsein Django. - Validate every post login destination with
is_safe_next, so//evil.examplecan't become an open redirect. - Log the original path before any rewrite, otherwise your app logs describe a URL the user never asked for.
Operational tests for your Python middleware
These run with no deployment. pip install fastapi httpx flask django pytest first, and keep the Django check in your own project's test settings.
# test_layers.py
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from fastapi_app import app as fastapi_app
from flask_app import app as flask_app
from routing_rules import decide, is_safe_next
from sessions import sign
FORGED = {"X-User-Id": "admin"}
@pytest.mark.parametrize(
"path,cookie,expected",
[
("/docs/v1", False, (308, "/docs")),
("/dashboard/billing", False, (307, "/login?next=/dashboard/billing")),
("/dashboard/billing", True, None),
("/pricing", False, None),
],
)
def test_decide(path, cookie, expected):
assert decide(path, cookie) == expected
def test_open_redirect_is_blocked():
assert not is_safe_next("//evil.example")
assert not is_safe_next("https://evil.example")
def test_fastapi_ignores_forged_header():
client = TestClient(fastapi_app)
assert client.get("/dashboard/billing", headers=FORGED).status_code == 401
def test_flask_ignores_forged_header():
client = flask_app.test_client()
assert client.get("/dashboard/billing", headers=FORGED).status_code == 401
def test_fastapi_accepts_real_session():
client = TestClient(fastapi_app, cookies={"session": sign("u_42")})
assert client.get("/dashboard/billing").json() == {"user": "u_42"}
def test_fastapi_slash_redirect_is_pinned():
probe = FastAPI()
@probe.get("/items/")
def items():
return []
response = TestClient(probe, follow_redirects=False).get("/items")
assert response.status_code == 307
Then run a short check against a preview deployment, because local tests can't see the routing layer. Use this checklist before you promote:
PREVIEW="https://your-preview.vercel.app"
curl -s -o /dev/null -w "%{http_code}\n" "$PREVIEW/docs/v1"
curl -s -o /dev/null -w "%{http_code}\n" "$PREVIEW/dashboard/billing"
curl -s -o /dev/null -w "%{http_code}\n" -H "X-User-Id: admin" -H "Cookie: session=fake" "$PREVIEW/dashboard/billing"
curl -s -L --max-redirs 5 -o /dev/null -w "%{num_redirects}\n" "$PREVIEW/items"
With the rules above, you should see 308, 307 and 401, and the last line should print a small number rather than a redirect error.
FAQ
How do I implement Vercel Python middleware with FastAPI safely?
Put redirects and coarse routing in a pure function that routing middleware calls, and verify identity inside FastAPI with a dependency. Then write a test proving a forged header gets a 401.
What changed with Vercel Python middleware in this release?
Routing middleware now supports Python through vercel-proxy. It runs before the app and can route, redirect and change request headers. It does not replace your framework's middleware, which still runs inside the app.
Should FastAPI, Flask and Django apps drop their own middleware?
No. Move URL level decisions out if that saves function calls, and keep sessions, authorization and request level logging in the app.
Related reading: Vercel Python Routing Middleware: The 308 and Auth Trap and Next.js Security Hardening: Middleware, Server Actions, and CSP Patterns That Hold Up.
If you are putting a Python agent or API into production and want a second pair of eyes on where these boundaries sit, see my agentic AI consulting service.
Comments
Join the discussionNo comments yet. Be the first to comment.



