Broken Access Control in FastAPI Applications
This example demonstrates the FastAPI flavor of broken access control, where most admin endpoints enforce authentication and role checks through a dependency, but one sibling route forgets to declare it and is reachable with no credentials at all.
FastAPI has no global authorization layer. A route is exactly as protected as the dependencies it declares, and nothing more. Authentication is wired in per-route with Depends(...), so a route that omits the guard is wide open while everything around it looks secure. That makes a forgotten Depends indistinguishable from a deliberately public endpoint until you read the code.
Please refer to the FastAPI Dependency Injection section before proceeding, as the dependency is the security perimeter here.
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel
app = FastAPI()
TOKENS = {
"admin-token": {"username": "admin", "role": "admin"},
"user-token": {"username": "user1", "role": "user"},
}
USERS = {
"admin": {"password": "admin123", "role": "admin"},
"user1": {"password": "user123", "role": "user"},
}
def require_admin(authorization: str = Header(None)) -> dict:
token = None
if authorization:
parts = authorization.split(" ", 1)
token = parts[1] if len(parts) == 2 and parts[0].lower() == "bearer" else authorization
user = TOKENS.get(token)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
return user
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/api/login")
def login(creds: LoginRequest):
user = USERS.get(creds.username)
if not user or user["password"] != creds.password:
raise HTTPException(status_code=401, detail="Invalid credentials")
token = "admin-token" if user["role"] == "admin" else "user-token"
return {"access_token": token, "token_type": "bearer"}
@app.get("/admin/dashboard")
def admin_dashboard(user: dict = Depends(require_admin)):
return {"message": "Welcome to the admin dashboard.", "user": user["username"]}
@app.get("/admin/config")
def admin_config(user: dict = Depends(require_admin)):
return {"config": "Sensitive system configuration here"}
@app.get("/admin/secret-data")
def secret_data():
return {
"sensitive_data": {
"api_key": "API-SECRET-123456",
"db_password": "super-secret-db-pass",
"internal_token": "internal-token-abcdef",
}
}
The require_admin dependency is written correctly. It fails closed: a missing or invalid token raises 401, and a valid non-admin token raises 403. Anything that declares Depends(require_admin) is properly protected.
def require_admin(authorization: str = Header(None)) -> dict:
...
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
return user
Two of the admin routes declare it and are safe. The bug is not in the guard, it is in the route that never asks for it. Looking closely at /admin/secret-data, the parameter list is empty: there is no Depends(require_admin), so FastAPI runs the handler for anyone.
@app.get("/admin/dashboard")
def admin_dashboard(user: dict = Depends(require_admin)): # protected
...
@app.get("/admin/config")
def admin_config(user: dict = Depends(require_admin)): # protected
...
@app.get("/admin/secret-data")
def secret_data(): # no Depends -> wide open
...
First, let’s send an unauthenticated request to /admin/config, which declares the dependency, and confirm it is rejected:
curl -s -o /dev/null -w "%{http_code}\n" \
"http://pyfu.local/fastapi-fu/fastapi-broken-access-control/admin/config"
401
As expected, the guard raised 401 because no valid token was supplied. Now the sibling route that forgot the dependency:
curl -s "http://pyfu.local/fastapi-fu/fastapi-broken-access-control/admin/secret-data"
{
"sensitive_data": {
"api_key": "API-SECRET-123456",
"db_password": "super-secret-db-pass",
"internal_token": "internal-token-abcdef"
}
}
Same unauthenticated request, opposite outcome: 200 OK and full disclosure. The endpoint never declared Depends(require_admin), so FastAPI enforced nothing, and any caller retrieves the secrets.
Why broken access control matters from an offensive security perspective
I hunt for the forgotten Depends because in FastAPI it is pure profit: no payload, no chain, no token. A single route that omitted its security dependency hands me whatever it returns, and that is routinely config dumps, API keys, internal tokens, or privileged actions reachable by an anonymous request. It is one of the highest-yield findings on FastAPI targets precisely because the framework enforces nothing globally, so protection is opt-in per route and the bug is a developer forgetting one parameter rather than a flawed algorithm. Coverage, not strength, is the weak point, and coverage gaps scale with the size of the route table.
What makes it worse than the Flask equivalent is FastAPI’s own reconnaissance surface. The OpenAPI schema at /openapi.json lists every route, and a route’s security requirements are visible right next to it, so I do not even have to guess: I diff the declared dependencies against the sensitive endpoints and the gap announces itself, see Exposed FastAPI Documentation Page.
When I assess a FastAPI app, these are the tells I hunt for:
- A dependency declared inconsistently across sibling routes. When
/admin/dashboardand/admin/configtakeDepends(require_admin)but/admin/secret-datadoes not, the asymmetry itself is the vulnerability; I enumerate every route and diff the dependency list against the sensitive set. - A 401/403 on one endpoint and a 200 on a neighboring one for the same unauthenticated request. I spray the whole
/admin/*and/internal/*namespace with no token and flag anything that does not reject me. - Authorization expressed only as per-route
Depends, with no router-level or app-level dependency and no default-deny, since that design guarantees a forgotten route is reachable. - Sensitive data assembled directly in the handler (hardcoded keys, DB passwords) with no dependency in the function signature above it.
This is distinct from the fail-open guard, where the dependency is present but returns instead of raising. Here the dependency is correct; it was simply never attached. The defender takeaway: enforce authorization where it cannot be forgotten, at the router or application level with default-deny, so a missing per-route dependency fails closed instead of open. This is closely related to the ownership-level gaps in Business Logic Vulnerabilities in FastAPI Applications.
Proof of exploitation
$ docker compose up -d --build fastapi-broken-access-control
Run the lab app (PyFuLabs/fastapi-fu/fastapi-broken-access-control). The guarded routes reject an unauthenticated request, while one sibling, /admin/secret-data, forgot its dependency and is reachable with no token:
curl -s -o /dev/null -w "/admin/config -> HTTP %{http_code}\n" \
"http://pyfu.local/fastapi-fu/fastapi-broken-access-control/admin/config"
curl -s "http://pyfu.local/fastapi-fu/fastapi-broken-access-control/admin/secret-data"
/admin/config -> HTTP 401
{
"sensitive_data": {
"api_key": "API-SECRET-123456",
"db_password": "super-secret-db-pass",
"internal_token": "internal-token-abcdef"
}
}
The neighboring /admin/dashboard and /admin/config routes declare Depends(require_admin) and return 401; this one simply forgot it.
Mitigation
The fix is to enforce authorization where it cannot be forgotten rather than relying on remembering to add Depends to every sensitive route, since the bug here is one endpoint that omitted the guard. Attach the dependency to the router so every route in the group inherits it, group routes by privilege level so a single mount point governs a whole feature area, and keep the public routes as the explicit exception rather than the protected ones.
from fastapi import APIRouter, Depends
# Every route mounted on this router inherits the guard; a forgotten
# endpoint cannot fall outside it.
admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])
@admin.get("/dashboard")
def admin_dashboard():
...
@admin.get("/secret-data") # protected by inheritance, even with no Depends here
def secret_data():
...
app.include_router(admin)
Keep the guard itself fail-closed (raising, never returning) as covered in FastAPI Dependency Injection, and treat any route that returns privileged data without an explicit, tested authorization dependency as broken.