Skip to main content

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

Updated 11 min readMarkdown
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_request in 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.

Vercel Python middleware and FastAPI middleware as two separate layers: the routing layer runs before the app and handles redirects, routing and header changes; the application layer runs inside the app and handles sessions, authorization and business rules

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.

Three Python framework entrypoints behind Vercel routing middleware: FastAPI is ASGI and checks sessions in a dependency, Flask is WSGI and checks sessions in before_request, Django offers ASGI or WSGI and checks sessions in a middleware class

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:

  1. Import, don't copy. Both layers import routing_rules.py. If the prefix list lives in two files, one will go stale.
  2. 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-proxy is the target.
  3. Pin vercel-proxy. It is a young package. Pin it in your lockfile and read the changelog before you bump it.
  4. 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.

Choosing a redirect status in Vercel Python middleware: 308 means moved for good, can be cached, use for URL moves; 307 means temporary, keeps method and body, use for login gates

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=False in FastAPI, strict_slashes=False in Flask, APPEND_SLASH = False in Django.
  • Validate every post login destination with is_safe_next, so //evil.example can'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:

Five point release checklist for Vercel Python middleware with FastAPI, Flask or Django: moved URLs answer 308, login gate answers 307, forged header gets 401, no redirect loops, app stays safe without the routing layer
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.

No comments yet. Be the first to comment.