Startup-time security posture and baseline management plugin for FastAPI applications.
Project description
FastAPI Safeguard
FastAPI Safeguard scans your routes at startup and fails on security issues — missing auth, exposed sensitive fields, CORS misconfigs, debug mode left on — before your application serves a single request.
Add it once. New unsecured routes fail the build.
Requires Python 3.9+
Highlights
- Baseline lock file — Like
package-lock.json, but for security findings. Accept existing tech debt; fail only on new regressions. Every change to security posture shows up as a diff insecurity_baseline.json. - Fail fast — Startup exits non-zero if new issues are found. Works as a CI gate out of the box.
- Explicit intent — Mark public endpoints with
@open_route. Everything else is expected to have auth. - Zero runtime overhead — Checks run once at startup, never per-request.
- Extensible — 9 core checks, 8 optional, and a simple base class for your own rules.
Quick Start
pip install fastapi-safeguard
from fastapi import FastAPI, Depends
from fastapi.security import OAuth2PasswordBearer
from fastapi_safeguard import FastAPISafeguard, open_route
app = FastAPI(lifespan=FastAPISafeguard.recommended().lifespan())
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@open_route # Explicitly marked as public — no warning
@app.get("/health")
async def health():
return {"status": "healthy"}
@app.get("/legacy-endpoint") # Forgot to add auth — will be flagged!
async def legacy():
return {"data": "visible to all"}
@app.get("/protected")
async def protected(token: str = Depends(oauth2_scheme)):
return {"data": "secret"}
First run — accept current state and create a baseline:
SECURITY_BASELINE_UPDATE=1 uvicorn main:app --reload
This creates security_baseline.json:
{
"schema_version": 1,
"generated_at": "2025-01-15T10:30:00Z",
"accepted_findings": [
"GET /legacy-endpoint has no accepted security dependency"
],
"checks_count": 9
}
Subsequent runs fail only on new findings:
uvicorn main:app --reload
✅ Startup succeeds if findings match baseline ❌ Startup fails if new security issues are detected
Table of Contents
- Highlights
- Quick Start
- Why
- Configuration
- Baseline Workflow
- Decorators
- Security Checks Reference
- OWASP API Top 10 Coverage
- Writing a Custom Check
- CI/CD Integration
- Startup Output Examples
- Environment Variables
- FAQ
- Roadmap
- Contributing
- License
Why
FastAPI treats every new endpoint as open by default. That's fine for prototyping. In production it means a forgotten Depends() silently exposes a route — and nobody notices until a pentest or an incident.
The failure modes are mundane and common:
- A mutation endpoint ships without auth.
- A response model is missing, so the ORM object serializes more fields than intended.
- A list endpoint has no pagination, and a single request dumps the whole table.
- CORS is set to
*withallow_credentials=True. - Debug mode is on in a staging environment that shares production data.
FastAPI Safeguard catches these at startup. It enforces that openness is intentional: unsecured routes must either carry a recognized auth dependency, be decorated with @open_route, or be explicitly accepted in the security baseline.
The baseline is the key part. Once you accept a finding, it's pinned. Future runs fail only on new findings. Adding an unsecured route shows up as a diff in the baseline file. Fixing one shows up as a removal. Security posture becomes reviewable in pull requests.
Configuration
Custom auth dependencies
Built-in schemes (OAuth2*, HTTPBasic, HTTPBearer, APIKey*) are recognized even when nested inside your own dependencies — the common Depends(get_current_user) pattern, where get_current_user itself depends on oauth2_scheme, works out of the box. Only fully custom auth needs registering so the check recognizes it:
from fastapi_safeguard import (
FastAPISafeguard,
DependencySecurityCheck,
ResponseModelSecurityCheck,
PaginationEnforcementCheck,
)
safeguard = FastAPISafeguard(checks=[
DependencySecurityCheck(
allowed_unsecured=["/openapi.json", "/docs", "/health"],
extra_dependencies=[my_custom_auth_dep],
),
ResponseModelSecurityCheck(),
PaginationEnforcementCheck(),
])
app = FastAPI(lifespan=safeguard.lifespan())
Adding optional checks
The recommended() preset includes only the 9 core checks. Add optional ones explicitly:
from fastapi_safeguard import FastAPISafeguard, HTTPSRedirectMiddlewareCheck
safeguard = FastAPISafeguard(checks=[
*FastAPISafeguard.recommended().checks,
HTTPSRedirectMiddlewareCheck(),
])
app = FastAPI(lifespan=safeguard.lifespan())
Existing lifespan
If your app already has a lifespan, call run_checks() directly:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi_safeguard import FastAPISafeguard
safeguard = FastAPISafeguard.recommended()
@asynccontextmanager
async def lifespan(app: FastAPI):
safeguard.run_checks(app)
db = await setup_database()
yield
await db.close()
app = FastAPI(lifespan=lifespan)
Baseline Workflow
Mental model
| You do this | The plugin does this |
|---|---|
| Run app with no baseline | Scans, prints findings, exits with error |
Set SECURITY_BASELINE_UPDATE=1 |
Writes a snapshot of current findings |
| Add a new unsecured route | Fails: finding not in baseline |
| Fix a finding | Old entry stays in file until you refresh |
| Refresh with update flag | Prunes resolved entries, accepts new ones |
Typical flow
# 1. First scan (will fail if there are findings)
uvicorn main:app --reload
# 2. Accept the current snapshot
SECURITY_BASELINE_UPDATE=1 uvicorn main:app --reload
# 3. Subsequent runs fail only on NEW findings
uvicorn main:app --reload
# 4. After fixing issues, prune resolved ones
SECURITY_BASELINE_UPDATE=1 uvicorn main:app --reload
You can also set update_baseline=True in code: FastAPISafeguard(..., update_baseline=True).
Reviewing baseline diffs
- Added lines = new risks being accepted.
- Removed lines = fixed issues.
- No change = security posture unchanged.
If a diff adds "GET /internal-metrics has no accepted security dependency", ask: was that endpoint meant to be open? If not, fix it instead of accepting.
Commit the baseline to git. Treat it like a contract — change it only when you're consciously accepting or removing risk.
Decorators
| Decorator | Effect | Other checks still run? | Use case |
|---|---|---|---|
@open_route |
Skips auth check only | Yes | Public status pages, health checks |
@disable_security_checks |
Skips all checks | No | Internal webhooks, exotic routes |
from fastapi_safeguard import open_route, disable_security_checks
@open_route
@app.get("/public-catalog")
async def catalog():
return {"items": []}
@disable_security_checks
@app.post("/internal-webhook")
async def webhook():
return {"ok": True}
Decorator placement above or below @app.get(...) doesn't matter — both attach to the same function.
Other ways to accept findings
| Strategy | When to use |
|---|---|
@open_route |
Intentionally public endpoint. Other checks still apply. |
@disable_security_checks |
Exceptional route. Use sparingly. |
| Baseline accept | Known tech debt. Persists until pruned. |
| Omit check from config | The check isn't relevant to your org. |
Security Checks Reference
Core checks (in recommended())
| Check | OWASP | What it catches | Example finding |
|---|---|---|---|
DependencySecurityCheck |
API2, API5 | Routes with no auth dependency | GET /items has no accepted security dependency |
UnsecuredAllowedMethodsCheck |
API5 | Unsafe methods on declared-open paths | POST /status exposes unsafe method(s) without security |
ResponseModelSecurityCheck |
API3 | Mutation endpoints missing response_model |
POST /users missing response_model for unsafe method(s) |
BodyModelEnforcementCheck |
API3, API6 | Raw dict/list body params (mass assignment risk) |
PATCH /users uses non-model raw body param(s): payload |
SensitiveFieldExposureCheck |
API3 | Response fields named password, token, secret, etc. |
GET /auth/me response_model exposes potentially sensitive fields: password |
SensitiveQueryParamCheck |
API3 | Sensitive data in query strings | GET /login exposes potentially sensitive data via query params: token |
PaginationEnforcementCheck |
API4 | List endpoints without limit/skip params |
GET /reports returns a collection without pagination params |
CORSMisconfigurationCheck |
API8 | Wildcard origins + allow_credentials=True |
CORS misconfiguration: allow_origins='*', credentials allowed |
DebugModeCheck |
API8 | app.debug = True |
Application running in debug mode |
Optional checks
These have higher false positive rates or are typically handled at the infrastructure level:
| Check | OWASP | What it catches | Why not default |
|---|---|---|---|
HTTPSRedirectMiddlewareCheck |
API8 | Missing HTTPS redirect middleware | Usually handled by load balancer |
TrustedHostMiddlewareCheck |
API8 | Missing host header protection | Usually handled upstream |
RateLimitingPresenceCheck |
API4 | No rate-limiting middleware detected | Often external (API gateway); weak heuristic |
ReturnTypeAnnotationCheck |
API3 | Missing return type annotations | Noisy; not a security issue |
WildcardPathCheck |
API3, API5 | Broad {path:path} params |
Legitimate use cases exist |
DangerousMethodExposureCheck |
— | TRACE/CONNECT methods | Rarely exposed via FastAPI |
SSRFParameterCheck |
— | Params named url, uri, target, etc. |
Too many false positives |
AdminRouteOpenCheck |
— | Unprotected /admin* routes |
Heuristic based on path naming |
OWASP API Top 10 Coverage
| OWASP | Risk | Core checks | Optional checks |
|---|---|---|---|
| API2 | Broken Authentication | DependencySecurityCheck | — |
| API3 | Excessive Data Exposure | ResponseModelSecurityCheck, BodyModelEnforcementCheck, SensitiveFieldExposureCheck, SensitiveQueryParamCheck | ReturnTypeAnnotationCheck, WildcardPathCheck |
| API4 | Unrestricted Resource Consumption | PaginationEnforcementCheck | RateLimitingPresenceCheck |
| API5 | Broken Function Level Authorization | DependencySecurityCheck, UnsecuredAllowedMethodsCheck | WildcardPathCheck |
| API6 | Mass Assignment | BodyModelEnforcementCheck | — |
| API8 | Security Misconfiguration | CORSMisconfigurationCheck, DebugModeCheck | HTTPSRedirectMiddlewareCheck, TrustedHostMiddlewareCheck |
Writing a Custom Check
from typing import Optional
from fastapi.routing import APIRoute
from fastapi_safeguard import FastAPISafeguard, RouteCheck
class MaxPathDepthCheck(RouteCheck):
"""Flag routes with deeply nested paths (more than 4 segments)."""
CATEGORY = "routing"
OWASP = ["API5"]
def _analyze(self, route: APIRoute) -> Optional[str]:
depth = len(route.path.strip("/").split("/"))
if depth > 4:
return f"{route.path} has {depth} path segments (max 4)"
return None
safeguard = FastAPISafeguard(checks=[
*FastAPISafeguard.recommended().checks,
MaxPathDepthCheck(),
])
Rules:
- Subclass
RouteCheck(notSecurityCheck) and implement_analyze— the base class handles@disable_security_checksandallowed_unsecuredpaths for you. - Return
Nonewhen compliant, a string when not. - Finding strings must be stable and deterministic — the baseline matches on exact text.
- Set
CATEGORYandOWASPfor the summary output.
CI/CD Integration
# GitHub Actions — importing the app module triggers lifespan startup and runs all checks
- name: Security route check
run: |
pip install fastapi uvicorn
python -c "import main"
To accept a new baseline in a controlled PR:
- name: Update baseline (manual trigger)
if: github.event_name == 'workflow_dispatch'
run: |
SECURITY_BASELINE_UPDATE=1 python -c "import main"
git add security_baseline.json
git commit -m "chore: update security baseline" || echo "No changes"
Startup Output Examples
New findings (startup fails):
Category Summary:
Category Total New Accepted OWASP
auth 1 1 0 API2/API5
schema 2 1 1 API3/API6
❌ Security check failed: new findings detected (not in baseline):
+ POST /users missing response_model for unsafe method(s)
+ GET /items has no accepted security dependency
To accept current findings run with SECURITY_BASELINE_UPDATE=1 or set update_baseline=True.
All findings accepted:
Category Summary:
Category Total New Accepted OWASP
auth 1 0 1 API2/API5
schema 2 0 2 API3/API6
✅ All security findings match accepted baseline (3 accepted).
Clean:
✅ All security checks passed (0 findings, 12 routes, 14 checks).
Environment Variables
| Variable | Purpose | Default |
|---|---|---|
SECURITY_BASELINE_PATH |
Path to baseline file | security_baseline.json |
SECURITY_BASELINE_UPDATE |
Set to 1 to accept current findings and write/refresh the baseline |
0 |
FAQ
Does this replace runtime auth? No. It catches common omissions at startup. You still need runtime authorization.
Will it block production if I forget to update the baseline? Yes, intentionally. Incorporate baseline updates into the PR that introduces the change.
Can I have different baselines per environment?
Yes — set SECURITY_BASELINE_PATH to point at different files.
What if I rename a route?
The old finding becomes resolved; refresh with SECURITY_BASELINE_UPDATE=1 to prune it.
How do I disable a specific check?
Omit it from the checks=[...] list when constructing FastAPISafeguard.
Roadmap
- Severity levels (warn vs error) and downgrade mechanism
- JSON output for machine parsing (SARIF-compatible)
- Category include/exclude filters via env vars
@warn_onlydecorator for non-fatal findings on selected routes- Scope / role enforcement scaffold
Contributing
- Fork & branch (
feat/my-improvement). - Keep PRs focused — one conceptual change per PR.
- Update README examples if behavior changes.
- Prefer small, composable checks over multi-purpose ones.
- Finding strings must be deterministic — no timestamps or randomness.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_safeguard-0.1.1.tar.gz.
File metadata
- Download URL: fastapi_safeguard-0.1.1.tar.gz
- Upload date:
- Size: 24.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b73891eabddf3346c1adf269b85d2318b6394ccd58545e061ff4d97879a204c4
|
|
| MD5 |
6da5a17d2612e3d04ad6ef64daf73ac5
|
|
| BLAKE2b-256 |
00e341128c38ace1e9f3439d4bd19395232297e6aacb6689e354a285b546daa1
|
File details
Details for the file fastapi_safeguard-0.1.1-py3-none-any.whl.
File metadata
- Download URL: fastapi_safeguard-0.1.1-py3-none-any.whl
- Upload date:
- Size: 17.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c88dbface1502d9ab6513c5d3b7f53ff2e7620f2c8255a41bf37eaa4d51d548f
|
|
| MD5 |
2fff5e0dfa54d8e8156941d64b527595
|
|
| BLAKE2b-256 |
025212c82de9f9e72aab72783db76c81b681d6b6c2443cd70eddb73b2fd0b804
|