Overview
The CVE-2025-68481 vulnerability describes a login flow weakness in FastAPI Projects using FastAPI Users for OAuth login. Prior to version 15.0.2, the login state tokens used during the OAuth dance are generated in a stateless fashion and carry no per-request entropy or session binding. The state token generation typically ignores session data (state_data is often empty), so the resulting token mainly contains a fixed audience and an expiration timestamp. On callback, the library only verifies the token’s signature and expiry, with no correlation to the browser that initiated the request. There is no server-side cache, no correlation cookie, and no mechanism to tie the callback to the initiating user session. An attacker can initiate an OAuth flow, capture the server-generated state, complete the upstream flow with their own provider account, and then lure a victim into loading the callback with the attacker’s code and state. Because the state token is valid for up to roughly an hour and not bound to the victim’s session, the victim can be tricked into finishing a login flow that associates with the attacker. This is effectively a login CSRF vulnerability and can lead to account takeover or unintended login to the attacker’s account. The issue was patched in FastAPI Users with version 15.0.2 (CVE-2025-68481, CWE-285, CWE-352). This class of vulnerability is a form of Broken Function Level Authorization where improper binding of an OAuth state to a user session enables cross-site credential flow manipulation.
Affected Versions
All versions prior to 15.0.2 of FastAPI Users (i.e., before the 15.0.2 patch).
Code Fix Example
FastAPI API Security Remediation
Vulnerable pattern (before patch):
from fastapi import FastAPI, RedirectResponse
import secrets
app = FastAPI()
CLIENT_ID = "YOUR_CLIENT_ID"
AUTH_BASE = "https://provider.example.com/oauth/authorize"
REDIRECT_URI = "http://localhost:8000/callback"
# Vulnerable: state token is generated without tying to the user session
def generate_state_token(state_data=None):
# Always ignores state_data; stateless, not bound to a session
return secrets.token_urlsafe(32)
@app.get('/authorize')
async def authorize():
state = generate_state_token(state_data={})
url = f"{AUTH_BASE}?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&state={state}"
return RedirectResponse(url)
@app.get('/callback')
async def callback(code: str, state: str):
# The callback only verifies the state token is well-formed; no per-session binding
# Exchange the code for tokens with the provider (omitted for brevity)
return {"status": "logged_in", "code": code, "state": state}
# Fixed pattern (after patch):
from fastapi import Request
from fastapi.responses import RedirectResponse
import time
STATE_STORE = {}
COOKIE_NAME = "oauth_state"
def exchange_code_for_token(code: str, state: str):
# Real implementation would exchange code for tokens
return "fake_token"
@app.get('/authorize_fixed')
async def authorize_fixed():
# Generate a per-request state and bind it to the client via a secure cookie
state = secrets.token_urlsafe(32)
STATE_STORE[state] = {"created": time.time()}
url = f"{AUTH_BASE}?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&state={state}"
resp = RedirectResponse(url)
resp.set_cookie(key=COOKIE_NAME, value=state, httponly=True, secure=True, samesite='lax')
return resp
@app.get('/callback_fixed')
async def callback_fixed(request: Request, code: str, state: str):
cookie_state = request.cookies.get(COOKIE_NAME)
if not cookie_state or cookie_state != state:
return {"error": "invalid_state"}
if state not in STATE_STORE:
return {"error": "unknown_state"}
token = exchange_code_for_token(code, state)
STATE_STORE.pop(state, None)
return {"status": "logged_in", "token": token}