PyFu

FastAPI Router

Python Web Development Frameworks

FastAPI Router is a mechanism that allows you to modularize your application into multiple logical groups of endpoints.

Instead of having all routes defined directly in the main application, you can organize them into separate modules and import them as routers.

This helps maintain clean, scalable, and maintainable codebases, especially as the number of endpoints grows.

When you use routers, you define your routes inside independent files and register them to the main FastAPI() app instance using include_router().

By default, routers can have prefixes, tags, and dependencies applied globally, which reduces code duplication and simplifies your API structure.

Router Example

Lets start by defining a router in a separate module in routers/user_router.py:

# user_router.py
from fastapi import APIRouter

router = APIRouter()

@router.get("/users")
async def get_users():
    return {"message": "List of users"}

@router.post("/users")
async def create_user():
    return {"message": "User created"}

First, we started by importing APIRouter class from FastAPI. This class allows us to create isolated route groups that can later be attached to the main application.

Then, we instantiated a new router instance via router = APIRouter().

This instance will hold all the routes defined inside this module.

After that, we defined a GET route on /users. Since it’s inside the router using router.get().

the full path will depend on how we include this router into the main app.

@router.get("/users")
async def get_users():
    return {"message": "List of users"}

Lastly, we defined a POST route on /users for creating a new user using router.post().

@router.post("/users")
async def create_user():
    return {"message": "User created"}

Like before, this is isolated inside the router.

Both routes are async, fully compatible with FastAPI’s asynchronous request handling model.

Now we will have the main application code which is:

from fastapi import FastAPI

from routers.user_router import router as user_router

app = FastAPI()

app.include_router(user_router, prefix="/api", tags=["Users"])

Now, all the routes in user_router are available under /api prefix for both GET /api/users and POST /api/users.

FastAPI automatically merges documentation, handles the routing, and applies any shared parameters or tags defined during include_router().

To test them using curl, we can simply execute the following:

curl -X GET http://localhost:8000/api/users  | jq
---
{
  "message": "List of users"
}

Router with Path and Query Parameters Example

Now we will build a route that demonstrates handling both path and query parameters inside a router.

FastAPI automatically parses, converts, and validates these arguments based on the type hints provided.

from fastapi import APIRouter

router = APIRouter()

@router.get("/users/{user_id}")
async def get_user(user_id: int, verbose: bool = False):
    if verbose:
        return {
            "user_id": user_id,
            "name": "Askar",
            "email": "askar@pyfu.io",
            "roles": ["admin", "user"],
            "region": "eu-west-1"
        }
    return {
        "user_id": user_id,
        "name": "Askar"
    }

Here we defined a GET route under /users/{user_id} where the user_id is a path parameter extracted directly from the URL and automatically converted into an integer.

@router.get("/users/{user_id}")
async def get_user(user_id: int, verbose: bool = False):

We also define a query parameter verbose with a default value False. If the client passes ?verbose=true, FastAPI will automatically convert it into a boolean.

Inside the function, we return different levels of user information depending on whether verbose is set to True or False.

To test this using curl, we can execute the following to test without verbose:

~ » curl -X GET http://localhost:8000/api/users/1  | jq

}
  "user_id": 1,
  "name": "Askar"
}

And we can execute this to test with verbose:

~ » curl -X GET http://localhost:8000/api/users/1\?verbose\=1  | jq

{
  "user_id": 1,
  "name": "Askar",
  "email": "askar@pyfu.io",
  "roles": [
    "admin",
    "user"
  ],
  "region": "eu-west-1"
}

To process JSON data in POST requests with strict type control, developers rely on Pydantic models.

As previously explained, FastAPI integrates directly with Pydantic to handle data parsing and validation efficiently.

Refer to FastAPI Pydantic Data Models for a deeper explanation.

Why routers matter from an offensive security perspective

A router is not just code organization, it is where the authorization boundary usually lives, which means it is also where it usually breaks. include_router(..., dependencies=[Depends(require_admin)]) attaches an auth check to every route in the group, so the security of a whole feature area rides on one line in the main app. When you audit a FastAPI app, the first thing to map is which routers carry an auth dependency and which do not. A route group mounted without one is reachable by anyone who knows the path, regardless of how careful the individual handlers look. See FastAPI Dependency Injection for how that control is enforced and where it fails open.

Two more things on this page are live attack surface, not API trivia:

  • Path parameters are object references. @router.get("/users/{user_id}") exposes user_id straight from the URL. If the handler returns that user’s data without checking that the caller owns it, that is IDOR / broken object-level authorization. Incrementing the integer walks other users’ records. This is the single most common real bug in FastAPI apps, covered in Business Logic Vulnerabilities in FastAPI Applications and Broken Access Control in Flask Applications.
  • The verbose switch leaks more than it should. The example returns roles, email, and region only when verbose=true. Conditional response shapes like this are a classic over-exposure pattern: a flag that flips on extra fields is a flag an attacker will flip. Prefer separate response models over runtime field toggling.

The prefix and tags applied at include_router() are purely cosmetic. They organize the docs (which are themselves an enumeration aid, see Exposed FastAPI Documentation Page) and enforce nothing. Never treat a path prefix as a security control.

Mitigation

Attach the authorization boundary to the router itself with include_router(..., dependencies=[...]) so every route in the group inherits the guard and a forgotten endpoint cannot fall outside it, and group routes by privilege level so a single mount point governs a whole feature area. The guard must fail closed by raising, as described in FastAPI Dependency Injection. For any route that takes an object reference in the path, enforce object-level authorization inside the handler by comparing the resolved identity against the requested resource, and return a single narrow response_model instead of toggling extra fields on a flag.

# Every route in this router requires admin; the boundary is one line.
app.include_router(
    admin_router,
    prefix="/api/admin",
    dependencies=[Depends(require_admin)],
)

@router.get("/users/{user_id}")
async def get_user(user_id: int, current_user=Depends(get_current_user)):
    if user_id != current_user.id and not current_user.is_admin:
        raise HTTPException(status_code=403)   # ownership check, stops IDOR
    ...