Broken Function Level Authorization

How to Fix Broken Function Level Authorization in FastAPI [March 2026] [CVE-2025-14546]

[Updated 2026-03] Updated CVE-2025-14546

Overview

In real-world OAuth flows, an attacker can exploit weak handling of the OAuth state parameter to induce actions in a victim’s context. CVE-2025-14546 describes fastapi-sso implementations prior to 0.19.0 where the get_login_url path generates a state value but does not persist or bind it to the user's session. The subsequent verify_and_process step accepts whatever state arrives in the callback without validating it against a trusted local value. This CSRF-like weakness enables an attacker to trick a victim into visiting a malicious callback URL and potentially link the attacker’s account to the victim’s internal account or perform unwanted actions within the victim’s session context. The root problem is a missing per-session binding of the OAuth state, which is a classic form of Broken Function Level Authorization: the server cannot reliably tie the authentication response to the legitimate user session, allowing cross-user misuse through a crafted callback flow. The CVE highlights the concrete risk in the fastapi-sso library when state handling is not tied to a server-side session before 0.19.0.

Affected Versions

fastapi-sso < 0.19.0

Code Fix Example

FastAPI API Security Remediation
Vulnerable pattern (no per-session state binding):

from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
import secrets

REDIRECT_URI = 'https://app.example.com/auth/callback'
LOGIN_URL_BASE = 'https://provider.example.com/auth'

app_vuln = FastAPI()

@app_vuln.get('/login')
async def login_vuln():
    state = secrets.token_urlsafe(16)
    login_url = f"{LOGIN_URL_BASE}?response_type=code&client_id=CLIENT&redirect_uri={REDIRECT_URI}&state={state}"
    # State is generated but not persisted per-user/session
    return RedirectResponse(login_url)

@app_vuln.get('/auth/callback')
async def callback_vuln(code: str, state: str):
    # No server-side check that 'state' matches a stored value for a session
    # Exchange 'code' for tokens and establish user session (omitted)
    return { 'status': 'ok', 'state_received': state }


Fixed pattern (bind state to user session, verify on callback):

from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, JSONResponse
from starlette.middleware.sessions import SessionMiddleware
import secrets

REDIRECT_URI = 'https://app.example.com/auth/callback'
LOGIN_URL_BASE = 'https://provider.example.com/auth'

app_fix = FastAPI()
app_fix.add_middleware(SessionMiddleware, secret_key='your-very-secure-secret')

@app_fix.get('/login')
async def login_fix(request: Request):
    state = secrets.token_urlsafe(16)
    # Persist the state per-user in server-side session
    request.session['oauth_state'] = state
    login_url = f"{LOGIN_URL_BASE}?response_type=code&client_id=CLIENT&redirect_uri={REDIRECT_URI}&state={state}"
    return RedirectResponse(login_url)

@app_fix.get('/auth/callback')
async def callback_fix(request: Request, code: str, state: str):
    # Validate callback state against the stored per-session value
    if request.session.get('oauth_state') != state:
        return JSONResponse({'error': 'invalid_state'}, status_code=400)
    # Consume/clear the state after use
    request.session.pop('oauth_state', None)
    # Proceed to exchange 'code' for tokens and establish user session (omitted)
    return { 'status': 'ok' }

CVE References

Choose which optional cookies to allow. You can change this any time.