Overview
Broken Function Level Authorization (BFLA) vulnerabilities in OAuth login flows can enable CSRF-like abuses where an attacker tricks a victim into completing an authentication callback that ends up authorizing the attacker’s account or linking it to the victim’s internal account. CVE-2025-14546 targets fastapi-sso versions before 0.19.0, where the OAuth state is generated but not persisted or bound to the user session, allowing a malicious callback to succeed even when the initiating session is different. CVE-2025-68481 affects FastAPI Users prior to 15.0.2, where the state token is stateless and lacks per-request entropy or session linkage, enabling login CSRF by presenting a valid state/callback to a victim. In both cases, the attack misleads the app into completing a login flow under the wrong context, potentially causing account takeovers or unintended logins depending on application logic.
These vulnerabilities exemplify broken function-level authorization because simply receiving a valid OAuth callback (with a code and state) is treated as sufficient to grant authentication, without ensuring that the callback originated from the user who started the flow. An attacker may craft or reuse a callback URL, or exploit a provider redirect to complete the flow in the victim’s browser without a binding to that specific session. The real-world impact ranges from session hijacking to account takeover, depending on how the application ties login to user identity and authorization rules. The CVEs highlight the need for strong state handling, session binding, and robust CSRF protections in FastAPI OAuth integrations.
Remediation involves both upgrading to patched libraries and implementing explicit server-side binding of OAuth state to user sessions. Upgrading to fastapi-sso >= 0.19.0 and fastapi-users >= 15.0.2 addresses the root cause in these projects. If upgrading is not immediate, implement per-request state storage (e.g., in a signed, server-side session) and verify the returned state on callback. Enable PKCE when supported, enforce secure SameSite cookies, and consider a correlation cookie to link the user’s browser to the OAuth initiation. Add tests that simulate mismatched state, missing session data, and callback manipulation to prevent regressions across code changes.
Affected Versions
fastapi-sso < 0.19.0; FastAPI Users < 15.0.2
Code Fix Example
FastAPI API Security Remediation
Vulnerable pattern (no state persistence or session binding)
from fastapi import FastAPI, Request
from starlette.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
import secrets
import os
MODE = os.environ.get("MODE", "VULNERABLE").upper() # VULNERABLE or FIXED
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="CHANGE_ME_FOR_PROD", session_cookie="session")
# Vulnerable login flow: generates state but does not bind to user session
@app.get("/login")
async def login(request: Request):
state = secrets.token_urlsafe(16)
# No persistence of state to session -> vulnerable to CSRF via callback
return RedirectResponse(f"/simulate_provider?state={state}")
# Simulated OAuth provider redirect (local mock)
@app.get("/simulate_provider")
async def simulate_provider(request: Request, state: str, override_code: str = None):
code = override_code if override_code else "mock_code"
# Redirect back to client callback with code and state
return RedirectResponse(f"/callback?code={code}&state={state}")
@app.get("/callback")
async def callback(request: Request, code: str = None, state: str = None):
if code is None or state is None:
return "Missing code or state in callback"
# Vulnerable: no check that state matches a stored value for this session
user = _exchange_code_for_token(code)
request.session["user_id"] = user["id"]
return f"Logged in as {user['id']} (mode={MODE})"
@app.get("/whoami")
async def whoami(request: Request):
return {"user": request.session.get("user_id")}
def _exchange_code_for_token(code: str):
if code == "attacker_code":
return {"id": "attacker_user"}
if code == "mock_code":
return {"id": "victim_user"}
return {"id": "guest"}
# Fixed pattern (state persisted and validated)
@app.get("/login_fixed")
async def login_fixed(request: Request):
state = secrets.token_urlsafe(16)
# Persist state in session to tie to this browser/session
request.session["oauth_state"] = state
return RedirectResponse(f"/simulate_provider_fixed?state={state}")
@app.get("/simulate_provider_fixed")
async def simulate_provider_fixed(request: Request, state: str, override_code: str = None):
code = override_code if override_code else "mock_code"
return RedirectResponse(f"/fixed_callback?code={code}&state={state}")
@app.get("/fixed_callback")
async def fixed_callback(request: Request, code: str = None, state: str = None):
if code is None or state is None:
return "Missing code or state in callback"
# Validate that the state matches the one stored in this session
stored_state = request.session.get("oauth_state")
if not stored_state or stored_state != state:
return "CSRF protection: state mismatch or missing"
# Consume the state once used
request.session.pop("oauth_state", None)
user = _exchange_code_for_token(code)
request.session["user_id"] = user["id"]
return f"Logged in as {user['id']} (mode=FIXED)"
@app.get("/whoami_fixed")
async def whoami_fixed(request: Request):
return {"user": request.session.get("user_id")}