Vercel's October 21 release brought routing middleware to Python, through the vercel-proxy package. It runs before your app and can route, redirect and rewrite request headers, and the example in the changelog redirects with an HTTP 308. Copy that example straight into a FastAPI project and you can ship two bugs in one commit: a redirect that browsers remember forever, and an auth check that lives in the wrong layer.
TL;DR
- Routing middleware runs before your FastAPI app. FastAPI's own
@app.middleware("http")runs inside it. They are different layers, so don't treat them as one stack. - 308 means "moved for good, keep the method and body". Use it for URLs that really moved, and use 307 for anything temporary like a login gate.
- Let routing middleware do coarse work (redirects, routing, cheap rejects). Do the real authorization inside the app, on every request, with a check that never trusts a header it didn't verify.
What shipped in Python routing middleware
Until this release, if your Vercel project was Python, anything you wanted to run before the app (a redirect map, an A/B split, a header tweak) either lived in static config or meant adding a JavaScript middleware file to a Python repo. Now the same layer speaks Python.
Per Vercel's changelog, three things are on the table:
- Routing: send a request to a different path or destination before your app sees it.
- Redirects: answer with a redirect without waking the function at all. The published example uses 308.
- Request header changes: add or rewrite headers on the way in, so the app receives extra context.
That third bullet is where people get hurt. It is very tempting to read a session cookie in routing middleware, look up the user, set an X-User-Id header and let every FastAPI route trust it. It works on day one. It also means your whole authorization model depends on a header, and headers are the one thing every client on the internet can send. OWASP's LLM06:2025 entry on excessive agency makes the same point for agent tools: limit what's exposed and enforce authorization independently downstream, not in the layer in front. If that header ever ends up deciding what an agent or tool is allowed to do, our guide to prompt injection and AI agent security walks through why that boundary has to hold on its own.
One honest flag before the code: the release is fresh, and I'm not going to paste a vercel-proxy handler signature from memory. Wire the handler exactly as Vercel's changelog and docs show it. Everything below is the part that doesn't change with the API surface: what to redirect, with which status, and where auth has to live.
Configure vercel-proxy safely
The safest routing middleware is boring. Keep its decisions in a pure function you can unit test, and make the handler a thin wrapper that calls it. That way the logic survives any API change, and your tests don't need a deployment.
# routing_rules.py from urllib.parse import urlsplit # Permanent moves only. These are safe to cache forever. PERMANENT_MOVES = { "/docs/v1": "/docs", "/blog/feed.xml": "/rss.xml", "/api/v1/reports": "/api/reports", } # Paths that need a signed in user get a temporary redirect to login. LOGIN_GATED_PREFIXES = ("/dashboard", "/settings") def is_safe_next(target: str) -> bool: """Allow only same site relative paths as a post login destination.""" parts = urlsplit(target) return ( target.startswith("/") and not target.startswith("//") 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={next_path}" return None if __name__ == "__main__": assert decide("/docs/v1", False) == (308, "/docs") assert decide("/dashboard/billing", False) == (307, "/login?next=/dashboard/billing") assert decide("/dashboard/billing", True) is None assert not is_safe_next("//evil.example") print("routing rules ok") Notice the cookie check is only "is there a cookie at all". That's on purpose. Middleware is the bouncer checking that you have a ticket, not the person reading the seat number. A missing cookie gets a cheap redirect to login and saves a function call. A present cookie passes through, and the app decides whether it's valid.
Why 308 is the wrong default for half your redirects
Under HTTP semantics (RFC 9110), 308 is a permanent redirect that keeps the request method and body, so a POST stays a POST. That's exactly what you want when /api/v1/reports really moved to /api/reports, and it's better than 301 for APIs because clients don't quietly turn your POST into a GET.
The footgun is "permanent". Permanent redirects are cacheable, so a browser that saw one can skip your server next time and go straight to the new location. If you copy the 308 from the example into a login gate, a maintenance page or an A/B split, some users will keep landing on the old target after you've changed the rule, and there's nothing in your deploy that fixes their browser cache.
My rule: 308 only for entries in a PERMANENT_MOVES style map that a human reviewed, 307 for everything that depends on who the user is or what week it is.
App middleware versus routing middleware
FastAPI has its own middleware, and its docs show the familiar shape: a function decorated with @app.middleware("http") that receives the request, calls call_next and can touch the response (the docs' sample targets Python 3.10+). It runs inside your app process, after routing middleware has already done its thing. Don't assume both layers share an ordering, a runtime or a config file. They don't.
Here's how I split the work:
| Job | Routing middleware | FastAPI middleware | FastAPI dependency |
|---|---|---|---|
| Permanent URL moves (308) | Yes | No | No |
| Login gate redirect (307) | Yes, cookie present or not | No | No |
| Request timing, request ids | No | Yes | No |
| Verify the session or token | No | No | Yes |
| Check the user owns the resource | No | No | Yes |
The last two rows are the whole point. Verification and ownership go in a dependency, because a dependency runs for the exact route that needs it, and FastAPI won't call your handler until it passes.
# app/main.py import hashlib import hmac import os import time from fastapi import Depends, FastAPI, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer app = FastAPI() bearer = HTTPBearer(auto_error=False) SECRET = os.environ.get("SESSION_SECRET", "dev only secret").encode() REPORT_OWNERS = {"r1": "alice", "r2": "bob"} @app.middleware("http") async def add_process_time(request: Request, call_next): start = time.perf_counter() response = await call_next(request) response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}" return response def sign(user_id: str) -> str: sig = hmac.new(SECRET, user_id.encode(), hashlib.sha256).hexdigest() return f"{user_id}.{sig}" def verify(token: str) -> str | None: user_id, _, sig = token.rpartition(".") if not user_id: return None expected = hmac.new(SECRET, user_id.encode(), hashlib.sha256).hexdigest() return user_id if hmac.compare_digest(sig, expected) else None async def current_user( creds: HTTPAuthorizationCredentials | None = Depends(bearer), ) -> str: # Identity comes from a token we verify here, never from an upstream header. if creds is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing token") user = verify(creds.credentials) if user is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token") return user @app.get("/api/reports/{report_id}") async def read_report(report_id: str, user: str = Depends(current_user)): owner = REPORT_OWNERS.get(report_id) if owner is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "no such report") if owner != user: raise HTTPException(status.HTTP_403_FORBIDDEN, "not your report") return {"report_id": report_id, "owner": owner} The HMAC token is deliberately tiny so the example runs with nothing but FastAPI installed. In production you'd swap verify for your real session store or JWT library. The shape stays the same: the dependency verifies, the handler checks ownership, and nothing reads X-User-Id.
This matters even more once services call each other. When one service acts on behalf of another (an agent calling a reports API, say), the header your middleware set is just one more input the downstream service shouldn't believe. That's the same pattern I use in multi agent system architecture: every hop verifies, nobody inherits trust from the hop before it.
Test redirects and authorization boundaries
Two kinds of tests, and both are cheap.
First, the auth boundary, in process, with FastAPI's TestClient (it needs httpx installed). The test that matters most is the spoofed header one, because that's the bug routing middleware tempts you into.
# tests/test_auth_boundary.py from fastapi.testclient import TestClient from app.main import app, sign client = TestClient(app) def auth(user: str) -> dict[str, str]: return {"Authorization": f"Bearer {sign(user)}"} def test_spoofed_identity_header_is_ignored(): r = client.get("/api/reports/r1", headers={"X-User-Id": "alice"}) assert r.status_code == 401 def test_tampered_token_is_rejected(): token = sign("bob").replace("bob", "alice", 1) r = client.get("/api/reports/r1", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 401 def test_valid_user_cannot_read_someone_elses_report(): r = client.get("/api/reports/r1", headers=auth("bob")) assert r.status_code == 403 def test_owner_can_read_and_timing_header_is_set(): r = client.get("/api/reports/r1", headers=auth("alice")) assert r.status_code == 200 assert r.json() == {"report_id": "r1", "owner": "alice"} assert "x-process-time" in r.headers Second, the redirects, against a real preview deployment, because that's the only place routing middleware actually runs. Turn off redirect following so you see the raw status and Location.
# scripts/check_redirects.py import sys import httpx CASES = [ # method, path, expected status, expected location suffix ("GET", "/docs/v1", 308, "/docs"), ("POST", "/api/v1/reports", 308, "/api/reports"), ("GET", "/dashboard/billing", 307, "/login?next=/dashboard/billing"), ] def main(base: str) -> int: failures = 0 with httpx.Client(follow_redirects=False, timeout=10) as client: for method, path, want_status, want_location in CASES: r = client.request(method, base.rstrip("/") + path) location = r.headers.get("location", "") ok = r.status_code == want_status and location.endswith(want_location) failures += not ok print(f"{'PASS' if ok else 'FAIL'} {method} {path} -> {r.status_code} {location}") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1])) Run it as python scripts/check_redirects.py https://your-preview-url.vercel.app in CI after each preview deploy. Edit CASES to match your own map; the point is that every 308 in it was put there on purpose.
Deployment checklist
Before this lands in main:
- [ ] Every 308 comes from a reviewed map of URLs that moved for good.
- [ ] Login gates, maintenance pages and experiments use 307.
- [ ] Routing middleware only checks that a cookie exists. It never decides who the user is.
- [ ] Every protected route has a FastAPI dependency that verifies the token and checks ownership.
- [ ] No route reads an identity header it didn't verify itself.
- [ ]
check_redirects.pypasses against the preview deployment.
What I'd do again: keep the middleware logic in a pure function from day one. When the API surface of a fresh feature shifts (and fresh features do), you change three lines of glue instead of your rules.
FAQ
How do I implement vercel python routing middleware safely?
Wire the handler the way Vercel's changelog and docs show, but keep the decisions in a pure Python function you can unit test. Use 308 only for URLs that moved permanently, 307 for anything temporary, and keep authorization inside your app with a dependency that verifies the token and checks ownership on every request. Then test redirects with redirect following turned off against a preview deployment.
What changed with vercel python routing middleware in October 2026?
On October 21, Vercel announced that routing middleware supports Python through vercel-proxy. It runs before your application and can route requests, return redirects and change request headers, and the changelog's example uses an HTTP 308 redirect.
Related reading: Next.js security hardening: middleware, server actions and CSP patterns that hold up and OpenAI sandbox findings change how leaders should design agent controls.
If you want an agent or API stack where every layer verifies instead of trusting the one before it, that's the agentic AI consulting work I take on.
Drop a comment if your setup looks different. Curious who's already moved a redirect map into Python middleware.




