FastAPI Security Audit
Audit FastAPI applications. FastAPI sits on Starlette + Pydantic — secure defaults are good, but custom code often bypasses them.
When this skill applies
- Reviewing FastAPI endpoints, dependencies, Pydantic models
- Auditing OAuth2 / JWT / API key auth flows
- Reviewing CORS, middleware, exception handlers
- Checking SQLAlchemy / SQLModel usage for SQL injection
- Reviewing async patterns for race conditions
Workflow
Follow ../_shared/audit-workflow.md.
Phase 1: Stack detection
grep -E '^fastapi|"fastapi"' requirements.txt pyproject.toml 2>/dev/null
python -c "import fastapi; print(fastapi.__version__)" 2>/dev/null
Phase 2: Inventory
# Route definitions
grep -rn '@app\.\|@router\.' . --include='*.py' | head -50
# Dependencies (DI)
grep -rn 'Depends(' . --include='*.py' | head -30
# Pydantic models (schemas)
grep -rn 'class .*BaseModel\|class .*pydantic' . --include='*.py' | head
# CORS / middleware
grep -rn 'add_middleware\|CORSMiddleware\|TrustedHostMiddleware' . --include='*.py'
# Raw SQL
grep -rn 'text(\|execute(\|raw_connection' . --include='*.py'
Phase 3: Detection — the checks
Pydantic schemas — input validation
- FAP-PYD-1 Endpoints accepting request bodies declare a Pydantic model — never
request: dict or request: Any.
- FAP-PYD-2 Field constraints set:
Field(min_length=..., max_length=..., gt=..., lt=...).
- FAP-PYD-3
model_config = ConfigDict(extra='forbid') (Pydantic v2) or class Config: extra = 'forbid' (v1) — rejects unknown fields, preventing mass assignment.
- FAP-PYD-4 Email fields use
EmailStr; URLs use HttpUrl; UUIDs use UUID4.
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class CreateUserIn(BaseModel):
model_config = ConfigDict(extra='forbid')
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=50)
# role is intentionally NOT here; set by server
Pydantic schemas — output filtering
Authentication
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def verify_password(plain, hashed):
return pwd_context.verify(plain, hashed)
Authorization
- FAP-AUTHZ-1 Per-resource authz checked in the dependency or route body. Auth dependency returns
User; the route must then check ownership / role for the specific resource.
- FAP-AUTHZ-2 OAuth2 scopes (if used) checked via
Security(get_current_user, scopes=[...]). Verify the dependency actually checks security_scopes.scopes.
- FAP-AUTHZ-3 Admin routes guarded by a separate dependency, not just "is authenticated".
def require_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
return user
@app.delete("/admin/users/{user_id}")
async def delete_user(user_id: UUID, _: User = Depends(require_admin)):
...
CORS
- FAP-CORS-1
CORSMiddleware with specific allow_origins, not ["*"] for credentialed APIs.
- FAP-CORS-2
allow_credentials=True ONLY with explicit origin list; with *, FastAPI/Starlette will block credentials, but ensure not bypassed via regex.
- FAP-CORS-3
allow_methods and allow_headers not blanket * if not needed.
Trusted host
- FAP-TH-1
TrustedHostMiddleware with allowed_hosts list — protects against Host header injection.
SQL injection (SQLAlchemy, SQLModel)
NoSQL injection
If using MongoDB / Beanie / Motor:
- FAP-NOSQL-1 Don't pass dict directly from request to query:
users.find_one(request.json()) — attacker controls operators ({"$gt": ""}).
Async patterns
- FAP-ASYNC-1 Endpoints declared
async def use async DB clients; sync_def endpoints called via FastAPI's thread pool. Don't mix sync I/O in async endpoints (blocks event loop).
- FAP-ASYNC-2 Shared mutable state (e.g., a module-level dict) accessed without locking → race conditions. Use Redis / DB / asyncio.Lock for shared state.
Background tasks
- FAP-BG-1
BackgroundTasks for fire-and-forget; errors caught and logged, not silently swallowed.
- FAP-BG-2 For long-running or critical background work, use Celery / Arq / Dramatiq — not BackgroundTasks (which dies with the worker process).
File uploads
- FAP-UP-1
UploadFile reads streamed; size limits enforced (FastAPI doesn't enforce by default — use Starlette's request.body size limits or check size after read).
- FAP-UP-2 Content type validated by magic bytes via
python-magic or similar, not just file.content_type (client-controlled).
- FAP-UP-3 File saved with sanitized filename (UUID + safe extension).
Error handling
- FAP-ERR-1 Global exception handler in production hides stack traces.
- FAP-ERR-2
HTTPException used for client errors; don't bubble Python exceptions verbatim.
- FAP-ERR-3 Validation errors (422) don't echo full schema paths if they reveal internals — Pydantic v2 errors are typically OK, but customize if exposing column names becomes a concern.
OpenAPI / docs
- FAP-DOC-1
/docs and /redoc disabled in production OR gated by auth:app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
Or:app = FastAPI(openapi_url="/api/v1/openapi.json")
# Then protect with router-level dependency
- FAP-DOC-2 OpenAPI schema doesn't include example values that are real credentials.
Session and cookies
If using Starlette's SessionMiddleware:
- FAP-SES-1
secret_key from env; not the example placeholder.
- FAP-SES-2
same_site='lax', https_only=True in production.
Dependencies
- FAP-DEP-1 FastAPI, Pydantic, Starlette versions current. Pydantic v2 is significantly different from v1; verify migration was complete.
- FAP-DEP-2
pip-audit clean.
Phase 4: Triage
Critical: endpoint accepting arbitrary dict body without Pydantic; raw SQL with string formatting; CORS * with credentials; docs reachable in prod with sensitive endpoints visible.
Phase 5: Report
Use ../_shared/findings-schema.md. Prefix IDs with FAP-.
Source: hlsitechio/claude-skills-security — distributed by TomeVault.
1---2name: fastapi-security3description: Security audit for FastAPI applications including dependency injection for auth, Pydantic schemas for input/output, OAuth2 scopes, async endpoint patterns, CORS middleware, SQL injection via SQLAlchemy raw queries, Starlette middleware, and FastAPI-specific patterns. Use this skill whenever the user mentions FastAPI, Pydantic, Starlette, OAuth2PasswordBearer, Depends, APIRouter, fastapi-users, SQLAlchemy in FastAPI, or asks "audit my FastAPI app", "FastAPI security review", "Pydantic safe". Trigger when the codebase contains `fastapi` in `requirements.txt` / `pyproject.toml`. Use when this capability is needed.4---56# FastAPI Security Audit78Audit FastAPI applications. FastAPI sits on Starlette + Pydantic — secure defaults are good, but custom code often bypasses them.910## When this skill applies1112- Reviewing FastAPI endpoints, dependencies, Pydantic models13- Auditing OAuth2 / JWT / API key auth flows14- Reviewing CORS, middleware, exception handlers15- Checking SQLAlchemy / SQLModel usage for SQL injection16- Reviewing async patterns for race conditions1718## Workflow1920Follow `../_shared/audit-workflow.md`.2122### Phase 1: Stack detection2324```bash25grep -E '^fastapi|"fastapi"' requirements.txt pyproject.toml 2>/dev/null26python -c "import fastapi; print(fastapi.__version__)" 2>/dev/null27```2829### Phase 2: Inventory3031```bash32# Route definitions33grep -rn '@app\.\|@router\.' . --include='*.py' | head -503435# Dependencies (DI)36grep -rn 'Depends(' . --include='*.py' | head -303738# Pydantic models (schemas)39grep -rn 'class .*BaseModel\|class .*pydantic' . --include='*.py' | head4041# CORS / middleware42grep -rn 'add_middleware\|CORSMiddleware\|TrustedHostMiddleware' . --include='*.py'4344# Raw SQL45grep -rn 'text(\|execute(\|raw_connection' . --include='*.py'46```4748### Phase 3: Detection — the checks4950#### Pydantic schemas — input validation5152- **FAP-PYD-1** Endpoints accepting request bodies declare a Pydantic model — never `request: dict` or `request: Any`.53- **FAP-PYD-2** Field constraints set: `Field(min_length=..., max_length=..., gt=..., lt=...)`.54- **FAP-PYD-3** `model_config = ConfigDict(extra='forbid')` (Pydantic v2) or `class Config: extra = 'forbid'` (v1) — rejects unknown fields, preventing mass assignment.55- **FAP-PYD-4** Email fields use `EmailStr`; URLs use `HttpUrl`; UUIDs use `UUID4`.5657```python58from pydantic import BaseModel, ConfigDict, EmailStr, Field5960class CreateUserIn(BaseModel):61 model_config = ConfigDict(extra='forbid')62 63 email: EmailStr64 password: str = Field(min_length=8, max_length=128)65 display_name: str = Field(min_length=1, max_length=50)66 # role is intentionally NOT here; set by server67```6869#### Pydantic schemas — output filtering7071- **FAP-PYD-5** Response model declared on endpoints to filter output:72 ```python73 @app.get("/users/{user_id}", response_model=UserOut)74 ```75 Without `response_model`, the endpoint returns whatever the function returns, including hidden fields.76- **FAP-PYD-6** Distinct input/output models (`UserIn`, `UserOut`, `UserInternal`) — don't reuse the same model for both directions; secrets leak.77- **FAP-PYD-7** `response_model_exclude` / `response_model_exclude_unset` not used to "hide" sensitive fields — explicit models are safer.7879#### Authentication8081- **FAP-AUTH-1** Every protected route declares an auth dependency:82 ```python83 @app.get("/me")84 async def me(user: User = Depends(get_current_user)):85 return user86 ```87- **FAP-AUTH-2** Global dependency for app-wide auth NOT mixed with per-route auth (confusing; pick one model).88- **FAP-AUTH-3** `OAuth2PasswordBearer` configured with `tokenUrl` matching the actual login endpoint.89- **FAP-AUTH-4** JWT validation: see `saas-security-pack/saas-code-security-review/references/jwt-validation.md`. Algorithm explicit, secret from env, expiry checked.90- **FAP-AUTH-5** Password hashing: `passlib[bcrypt]` or Argon2; not plain SHA-256.9192```python93from passlib.context import CryptContext94pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")9596def verify_password(plain, hashed):97 return pwd_context.verify(plain, hashed)98```99100#### Authorization101102- **FAP-AUTHZ-1** Per-resource authz checked in the dependency or route body. Auth dependency returns `User`; the route must then check ownership / role for the specific resource.103- **FAP-AUTHZ-2** OAuth2 scopes (if used) checked via `Security(get_current_user, scopes=[...])`. Verify the dependency actually checks `security_scopes.scopes`.104- **FAP-AUTHZ-3** Admin routes guarded by a separate dependency, not just "is authenticated".105106```python107def require_admin(user: User = Depends(get_current_user)) -> User:108 if not user.is_admin:109 raise HTTPException(status_code=403, detail="Forbidden")110 return user111112@app.delete("/admin/users/{user_id}")113async def delete_user(user_id: UUID, _: User = Depends(require_admin)):114 ...115```116117#### CORS118119- **FAP-CORS-1** `CORSMiddleware` with specific `allow_origins`, not `["*"]` for credentialed APIs.120- **FAP-CORS-2** `allow_credentials=True` ONLY with explicit origin list; with `*`, FastAPI/Starlette will block credentials, but ensure not bypassed via regex.121- **FAP-CORS-3** `allow_methods` and `allow_headers` not blanket `*` if not needed.122123#### Trusted host124125- **FAP-TH-1** `TrustedHostMiddleware` with `allowed_hosts` list — protects against Host header injection.126127#### SQL injection (SQLAlchemy, SQLModel)128129- **FAP-SQL-1** `session.execute(text("SELECT ..."))` with f-strings or `%` formatting = injection. Use bound params:130 ```python131 # BAD132 session.execute(text(f"SELECT * FROM users WHERE id = {user_id}"))133 134 # GOOD135 session.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id})136 ```137- **FAP-SQL-2** SQLAlchemy Core / ORM `filter(User.id == user_id)` is parameterized — safe.138- **FAP-SQL-3** `query.filter(text(...))` patterns reviewed.139140#### NoSQL injection141142If using MongoDB / Beanie / Motor:143- **FAP-NOSQL-1** Don't pass dict directly from request to query: `users.find_one(request.json())` — attacker controls operators (`{"$gt": ""}`).144145#### Async patterns146147- **FAP-ASYNC-1** Endpoints declared `async def` use async DB clients; `sync_def` endpoints called via FastAPI's thread pool. Don't mix sync I/O in async endpoints (blocks event loop).148- **FAP-ASYNC-2** Shared mutable state (e.g., a module-level dict) accessed without locking → race conditions. Use Redis / DB / asyncio.Lock for shared state.149150#### Background tasks151152- **FAP-BG-1** `BackgroundTasks` for fire-and-forget; errors caught and logged, not silently swallowed.153- **FAP-BG-2** For long-running or critical background work, use Celery / Arq / Dramatiq — not BackgroundTasks (which dies with the worker process).154155#### File uploads156157- **FAP-UP-1** `UploadFile` reads streamed; size limits enforced (FastAPI doesn't enforce by default — use Starlette's `request.body` size limits or check size after read).158- **FAP-UP-2** Content type validated by magic bytes via `python-magic` or similar, not just `file.content_type` (client-controlled).159- **FAP-UP-3** File saved with sanitized filename (UUID + safe extension).160161#### Error handling162163- **FAP-ERR-1** Global exception handler in production hides stack traces.164- **FAP-ERR-2** `HTTPException` used for client errors; don't bubble Python exceptions verbatim.165- **FAP-ERR-3** Validation errors (422) don't echo full schema paths if they reveal internals — Pydantic v2 errors are typically OK, but customize if exposing column names becomes a concern.166167#### OpenAPI / docs168169- **FAP-DOC-1** `/docs` and `/redoc` disabled in production OR gated by auth:170 ```python171 app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)172 ```173 Or:174 ```python175 app = FastAPI(openapi_url="/api/v1/openapi.json")176 # Then protect with router-level dependency177 ```178- **FAP-DOC-2** OpenAPI schema doesn't include example values that are real credentials.179180#### Session and cookies181182If using Starlette's `SessionMiddleware`:183- **FAP-SES-1** `secret_key` from env; not the example placeholder.184- **FAP-SES-2** `same_site='lax'`, `https_only=True` in production.185186#### Dependencies187188- **FAP-DEP-1** FastAPI, Pydantic, Starlette versions current. Pydantic v2 is significantly different from v1; verify migration was complete.189- **FAP-DEP-2** `pip-audit` clean.190191### Phase 4: Triage192193Critical: endpoint accepting arbitrary dict body without Pydantic; raw SQL with string formatting; CORS `*` with credentials; docs reachable in prod with sensitive endpoints visible.194195### Phase 5: Report196197Use `../_shared/findings-schema.md`. Prefix IDs with `FAP-`.198199---200> Source: [hlsitechio/claude-skills-security](https://github.com/hlsitechio/claude-skills-security) — distributed by [TomeVault](https://tomevault.io).201<!-- tomevault:4.0:skill_md:2026-06-15 -->