Skip to main content

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 in security_baseline.json.
  • Fail fast — Startup exits non-zero if new issues are found. Works as a CI gate out of the box.
  • CLI & test helperfastapi-safeguard check app.main:app scans without running your app; assert_safeguard(app) does the same inside pytest.
  • 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


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 * with allow_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.


CLI

Scan an app without wiring the lifespan or starting a server — the app is only imported, never run. Use it when you want the security gate in CI (or locally) instead of, or in addition to, the startup gate:

fastapi-safeguard check app.main:app                  # attribute defaults to :app
fastapi-safeguard check app.main:create_app --factory # app factories
fastapi-safeguard check main:app --update-baseline    # accept current findings
Flag Purpose
--app-dir DIR Directory to add to the import path (default: .)
--baseline PATH Baseline file (default: SECURITY_BASELINE_PATH or security_baseline.json)
--update-baseline Accept current findings and write/refresh the baseline
--factory Call the imported attribute to get the app

Exit codes: 0 pass, 1 new findings, 2 app could not be loaded. Also available as python -m fastapi_safeguard.


Using in Tests

assert_safeguard(app) runs the same scan as a plain assertion — no SystemExit, no stdout noise, baseline respected but never written:

from fastapi_safeguard import assert_safeguard
from app.main import app

def test_security_posture():
    assert_safeguard(app)  # raises AssertionError listing any new findings

It returns a ScanResult for further assertions, and accepts checks=[...], baseline_path=..., or a preconfigured safeguard= for custom setups.


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 (not SecurityCheck) and implement _analyze — the base class handles @disable_security_checks and allowed_unsecured paths for you.
  • Return None when compliant, a string when not.
  • Finding strings must be stable and deterministic — the baseline matches on exact text.
  • Set CATEGORY and OWASP for the summary output.

CI/CD Integration

# GitHub Actions — scans the app without running it; exits 1 on new findings
- name: Security route check
  run: |
    pip install fastapi-safeguard
    fastapi-safeguard check main:app

To accept a new baseline in a controlled PR:

- name: Update baseline (manual trigger)
  if: github.event_name == 'workflow_dispatch'
  run: |
    fastapi-safeguard check main:app --update-baseline
    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_only decorator for non-fatal findings on selected routes
  • Scope / role enforcement scaffold

Contributing

  1. Fork & branch (feat/my-improvement).
  2. Keep PRs focused — one conceptual change per PR.
  3. Update README examples if behavior changes.
  4. Prefer small, composable checks over multi-purpose ones.
  5. Finding strings must be deterministic — no timestamps or randomness.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_safeguard-0.2.0.tar.gz (37.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_safeguard-0.2.0-py3-none-any.whl (24.7 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_safeguard-0.2.0.tar.gz.

File metadata

  • Download URL: fastapi_safeguard-0.2.0.tar.gz
  • Upload date:
  • Size: 37.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.6

File hashes

Hashes for fastapi_safeguard-0.2.0.tar.gz
Algorithm Hash digest
SHA256 ece5b62747bb5f21b5175a310738b42cbf9d612ad667ce0906a3306b95d21936
MD5 60cb9044cc62d38e66e28fe10128b50f
BLAKE2b-256 bc5c75a16bae07ae4801e6a607306de2d7a207d31d93ccd089e161889a9e3f30

See more details on using hashes here.

File details

Details for the file fastapi_safeguard-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for fastapi_safeguard-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6351fea0dfae55d35d9c74259aa67fe523d1ac68ab3b47529632ded1aab0b2cd
MD5 7c32a1736e02bf3a6c9ddf75fdd9491a
BLAKE2b-256 ae0a556ce4b370ddf79c179d72d47130786c93360218ab00f72768dae8dca9e6

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page