PyFu

Business Logic Vulnerabilities in FastAPI Applications

Python-based Web Application Attacks

Business Logic Vulnerabilities occur when an application’s core functionality can be abused due to flaws in how developers implemented or enforced the intended workflows, rather than due to missing technical controls like authentication or input validation.

In FastAPI applications, developers have full control over request handling, parameter parsing, object access, and user flow logic.

This flexibility often creates opportunities for business logic flaws when developers forget to enforce proper ownership, authorization, or contextual validations after authentication.

If the application fails to enforce proper ownership, authorization checks, or context validations, attackers may exploit these gaps by manipulating parameters or accessing resources they shouldn’t be able to.

Unlike classic technical vulnerabilities, business logic vulnerabilities usually require a deeper understanding of how the application is supposed to behave and how it handles state, user roles, and data access internally.

To demonstrate how this happens in practice, let’s look at the following FastAPI application, which simulates a small system with administrators and regular users, and exposes a vulnerable profile endpoint.

from fastapi import FastAPI, Depends, HTTPException, status, Header
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
import hashlib
from typing import Optional, Dict

app = FastAPI()

# ----- Password hashing helper -----
def hash_password(password: str) -> str:
    return hashlib.sha256(password.encode()).hexdigest()

hashed_pw = hash_password("pyfu")

# ----- Simulated user "database" with high user IDs -----
USERS: Dict[int, Dict] = {
    15202: {"username": "pyfu", "password": hashed_pw, "name": "PyFu Admin", "hired": True,  "role": "admin"},
    15203: {"username": "bob", "password": hashed_pw, "name": "Bob",        "hired": False, "role": "user"},
    15204: {"username": "alice", "password": hashed_pw, "name": "Alice",    "hired": True,  "role": "user"},
    15205: {"username": "charlie", "password": hashed_pw, "name": "Charlie","hired": False, "role": "user"},
    15206: {"username": "dave", "password": hashed_pw, "name": "Dave",      "hired": False, "role": "user"},
    15207: {"username": "eve", "password": hashed_pw, "name": "Eve",        "hired": True,  "role": "user"},
    15208: {"username": "frank", "password": hashed_pw, "name": "Frank",    "hired": False, "role": "user"},
    15209: {"username": "grace", "password": hashed_pw, "name": "Grace",    "hired": True,  "role": "user"},
    15210: {"username": "heidi", "password": hashed_pw, "name": "Heidi",    "hired": False, "role": "user"},
    15211: {"username": "ivan", "password": hashed_pw, "name": "Ivan",      "hired": True,  "role": "user"},
    15212: {"username": "judy", "password": hashed_pw, "name": "Judy",      "hired": False, "role": "user"},
    15213: {"username": "mallory", "password": hashed_pw, "name": "Mallory","hired": False, "role": "user"},
    15214: {"username": "oscar", "password": hashed_pw, "name": "Oscar",    "hired": True,  "role": "user"},
    15215: {"username": "peggy", "password": hashed_pw, "name": "Peggy",    "hired": True,  "role": "user"},
    15216: {"username": "sybil", "password": hashed_pw, "name": "Sybil",    "hired": False, "role": "user"},
    15217: {"username": "trent", "password": hashed_pw, "name": "Trent",    "hired": True,  "role": "user"},
    15218: {"username": "victor", "password": hashed_pw, "name": "Victor",  "hired": False, "role": "user"},
}

# Simple "token store": token -> user_id
TOKENS: Dict[str, int] = {}

# ----- Pydantic models -----
class LoginRequest(BaseModel):
    username: str
    password: str

class UserProfile(BaseModel):
    user_id: int
    username: str
    name: str
    hired: bool
    role: str

# ----- Helper functions -----
def find_user_by_username(username: str):
    for user_id, user in USERS.items():
        if user["username"] == username:
            return user_id, user
    return None, None

# Dependency to get current authenticated user from an "Authorization" header
async def get_current_user(authorization: Optional[str] = Header(default=None)) -> Dict:
    """
    Expects a header: Authorization: Bearer <token>
    token is looked up in TOKENS to find the associated user.
    """
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing or invalid Authorization header"
        )

    token = authorization.split(" ", 1)[1]
    user_id = TOKENS.get(token)
    if not user_id:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired token"
        )

    user = USERS.get(user_id)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="User not found"
        )

    # Attach user_id into the dict for convenience
    user_with_id = {**user, "user_id": user_id}
    return user_with_id

# ----- Routes -----

@app.post("/login")
async def login(credentials: LoginRequest):
    """
    Simple login that issues a dummy access token if the username/password is correct.
    """
    user_id, user = find_user_by_username(credentials.username)
    if not user or user["password"] != hash_password(credentials.password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid username or password"
        )

    # In a real app this would be a secure random token or JWT
    token = f"token-{user_id}"
    TOKENS[token] = user_id

    return {
        "access_token": token,
        "token_type": "bearer",
        "user_id": user_id,
        "role": user["role"]
    }

@app.get("/dashboard")
async def dashboard(current_user: Dict = Depends(get_current_user)):
    """
    Admin-only dashboard. Lists all users.
    """
    if current_user["role"] != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Admin role required"
        )

    return {
        "message": "Admin dashboard",
        "users": USERS
    }

# Vulnerable profile endpoint
@app.get("/profile/{user_id}", response_model=UserProfile)
async def profile(user_id: int, current_user: Dict = Depends(get_current_user)):
    """
    Business Logic Vulnerability:
    Any authenticated user can access any profile by changing the user_id path parameter.
    """

    user = USERS.get(user_id)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="User not found"
        )

    # No check that current_user["user_id"] == user_id
    # No ownership or authorization check at all
    return UserProfile(
        user_id=user_id,
        username=user["username"],
        name=user["name"],
        hired=user["hired"],
        role=user["role"],
    )

This FastAPI application simulates a basic system where users can log in, receive an access token, and then use that token to access endpoints as either administrators or regular users.

Each user has a high numeric ID starting from 15202, a username, a SHA256-hashed password (all users share the same password pyfu for demonstration), a display name, a hiring status, and a role (admin or user).

The /login endpoint validates the username and password and, if successful, issues a dummy access token. This token is stored in an in-memory TOKENS dictionary and is expected to be sent by the client in the Authorization: Bearer <token> header on subsequent requests.

The get_current_user dependency uses this header to resolve the caller’s identity and attach the corresponding user data to the request.

Our focus here is the /profile/{user_id} route, which returns a user’s profile based on the user_id passed in the URL path. The route correctly uses Depends(get_current_user) to ensure that only authenticated users can access it.

However, once the request reaches the handler, no further authorization logic is applied to verify whether the authenticated user is actually allowed to view the requested profile.

This is the vulnerable part of the code:

@app.get("/profile/{user_id}", response_model=UserProfile)
async def profile(user_id: int, current_user: Dict = Depends(get_current_user)):
    """
    Business Logic Vulnerability:
    Any authenticated user can access any profile by changing the user_id path parameter.
    """

    user = USERS.get(user_id)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="User not found"
        )

    # No check that current_user["user_id"] == user_id
    # No ownership or authorization check at all
    return UserProfile(
        user_id=user_id,
        username=user["username"],
        name=user["name"],
        hired=user["hired"],
        role=user["role"],
    )

As long as the request includes a valid Authorization header and passes the get_current_user dependency, the application will accept any user_id in the path, look up that user in USERS, and return their full profile.

There is no check that current_user["user_id"] matches the requested user_id, nor is there any role-based restriction limiting which profiles a regular user is allowed to view.

This is a classic business logic vulnerability where the authentication mechanism is technically correct, and the dependency injection is working as intended, but the authorization logic for the business operation itself (viewing a profile) is incomplete.

An attacker who logs in as a regular user can simply iterate over user IDs like 15202, 15203, 15204, and so on:


$ curl -H "Authorization: Bearer token-15203" http://localhost:8000/profile/15202

$ curl -H "Authorization: Bearer token-15203" http://localhost:8000/profile/15204

Each of these requests will return the profile for another user, including hiring status and role information, even though the attacker is only supposed to access their own profile.

This example demonstrates how Business Logic Vulnerabilities in FastAPI applications often appear not because authentication is missing, but because ownership and authorization checks are not enforced on core operations.

Why business logic flaws matter from an offensive security perspective

I value object-level authorization gaps in FastAPI because the framework’s own correctness hides them. Depends(get_current_user) runs, the token validates, the request looks fully authenticated, and the only thing missing is the one comparison that nobody wrote: does this caller own the id they asked for. That makes the bug invisible to scanners that key on auth failures, because there is no auth failure. What it yields is direct IDOR over every record the endpoint serves, and with sequential ids like the 15202-based scheme here, enumeration is a for-loop. A low-privilege token reads the admin’s profile, which means the role gate is irrelevant once I go through the per-object route.

The tells I look for when assessing a FastAPI app:

  • A resource id in the path or query (/profile/{user_id}, ?account=) where the handler does Depends(get_current_user) but never compares current_user["user_id"] to that id. The dependency proves identity, not ownership, and that split is the whole bug.
  • Numeric, sequential, or otherwise guessable identifiers returned in login responses, which tell me enumeration will be cheap before I even test access.
  • Role checks at some routes but not at the per-object route. When /dashboard returns 403 for a user token yet /profile/{id} serves any id, the inconsistency points straight at the missing object-level check.
  • Handlers that look up the record from the path value and return it directly, with the only guard being the authentication dependency rather than an ownership predicate.

The defender takeaway: authentication answers who you are, never what you may touch, so every record access needs an explicit server-side ownership check. This is the dependency-injection seam discussed in FastAPI Dependency Injection.

Proof of exploitation

Run the lab app (PyFuLabs/fastapi-fu/fastapi-business-logic). Every user shares the password pyfu; logging in as the low-privilege bob returns a token, and the profile endpoint then serves any user id with no ownership check:

curl -s -X POST http://pyfu.local/fastapi-fu/fastapi-business-logic/login \
  -H "Content-Type: application/json" -d '{"username":"bob","password":"pyfu"}'
# -> {"access_token":"token-15203","role":"user",...}

curl -s -H "Authorization: Bearer token-15203" \
  http://pyfu.local/fastapi-fu/fastapi-business-logic/profile/15202
{"user_id":15202,"username":"pyfu","name":"PyFu Admin","hired":true,"role":"admin"}

bob’s token read the admin’s profile, which is IDOR / broken object-level authorization. The role-gated /dashboard correctly returns 403, which shows the flaw is the missing per-object check, not the role check.

Mitigation

The fix is to enforce object-level authorization on every record access rather than only role checks at the route. The /profile/{id} handler must confirm that the authenticated user owns or is explicitly permitted to view the requested id, instead of serving any id to any valid token; deny by default and check ownership server-side on each request. Predictable sequential ids make enumeration trivial, so pair the authorization check with non-guessable identifiers where feasible.