Put an authentication dependency on every protected path operation. Read the Authorization: Bearer token with HTTPBearer(auto_error=False), compare it to the server-side credential with hmac.compare_digest, and raise 401 with WWW-Authenticate: Bearer when it is missing or wrong. Keep the health probe open; a critical function like key rotation must never run unauthenticated.
- List the path operations that do something sensitive or change state. A critical function (here, rotating the signing key) must require authentication; a liveness probe can stay public.
- Add an authentication dependency with Depends(...) to each protected route (or dependencies=[...] on the decorator); read the credential with fastapi.security.HTTPBearer.
- Compare the presented token to the server-side credential with hmac.compare_digest (constant time).
- Raise HTTPException(401, headers={"WWW-Authenticate": "Bearer"}) when the credential is missing or does not match; the card uses HTTPBearer(auto_error=False) so one code path answers both cases.
- Ignore client-supplied hints about the caller (X-Internal, X-Forwarded-For, Host); the client writes them.
- Add regression tests that call each protected route with no credential and with a spoofed internal header, and assert 401.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def add_routes(app: FastAPI) -> None:
@app.get("/internal/metrics")
def metrics():
# No dependency: the caller is trusted because the route is "internal".
return service.metrics()
@app.post("/internal/rotate-key")
def rotate_key():
return {"keyId": service.rotate_key()}
@app.get("/health")
def health():
return {"status": "ok"}With IMPL=vulnerable, 6 of 9 tests fail (5 exploit tests).
The fix: require an authenticated operator
def require_operator(creds: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> None:
if creds is None or not service.valid_operator_token(creds.credentials):
raise HTTPException(
status_code=401,
detail="operator authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
def add_routes(app: FastAPI) -> None:
@app.get("/internal/metrics", dependencies=[Depends(require_operator)])
def metrics():
return service.metrics()
@app.post("/internal/rotate-key", dependencies=[Depends(require_operator)])
def rotate_key():
return {"keyId": service.rotate_key()}
@app.get("/health")
def health():
return {"status": "ok"}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
def require_internal(request: Request) -> None:
# A client-controlled header is not a credential: anyone can send it.
if request.headers.get("X-Internal") != "1":
raise HTTPException(status_code=401, detail="internal callers only")
def add_routes(app: FastAPI) -> None:
@app.get("/internal/metrics", dependencies=[Depends(require_internal)])
def metrics():
return service.metrics()
@app.post("/internal/rotate-key", dependencies=[Depends(require_internal)])
def rotate_key():
return {"keyId": service.rotate_key()}
@app.get("/health")
def health():
return {"status": "ok"}With IMPL=wrong, 5 of 9 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: reach a critical function without being an authenticated operator.
def test_exploit_metrics_without_authentication_is_refused(client):
assert client.get("/internal/metrics").status_code == 401
def test_exploit_rotate_key_without_authentication_is_refused(client):
assert client.post("/internal/rotate-key").status_code == 401
def test_exploit_spoofed_internal_header_on_metrics_is_refused(client):
assert client.get("/internal/metrics", headers={"X-Internal": "1"}).status_code == 401
def test_exploit_spoofed_internal_header_on_rotate_key_is_refused(client):
assert client.post("/internal/rotate-key", headers={"X-Internal": "1"}).status_code == 401
def test_exploit_invalid_bearer_token_is_refused(client):
res = client.get("/internal/metrics", headers={"Authorization": "Bearer not-the-token"})
assert res.status_code == 401- "It's internal" is not authentication: a reachable path operation needs a credential, because network position can be bypassed or reached from a compromised neighbour.
- A client-supplied header such as X-Internal is set by the client, so a dependency that checks it authenticates nobody; only a value your own trusted proxy sets and strips can be relied on, and a real credential is better.
- HTTPBearer only checks that an Authorization: Bearer header is present; it does not validate the token. With its default auto_error=True it answers a missing or malformed header with 401 and WWW-Authenticate: Bearer (checked on FastAPI 0.142.2; versions before 0.122.0 answered 403), but any non-empty bearer value passes it, so the dependency must still compare the token to your credential.
- Comparing tokens with == leaks timing; use hmac.compare_digest.
- A dependency declared but not attached to the route (forgetting dependencies=[...] or the Depends parameter) runs nothing; confirm the dependency actually guards the handler.
- Returning 200 on a missing credential to avoid breaking clients defeats the control; fail closed with 401.
- A single long-lived shared token is the simplest credential and is what this card uses to keep the tests self-contained; for calls between services, ASVS 5.0.0 13.2.1 asks for individual service accounts, short-lived tokens or certificates (mutual TLS) rather than unchanging shared keys, and the credential belongs in a secret store, not in source (Bandit's B105 flags the demo constant).
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authentication/python-fastapi does this for you).
- Against your own endpoint, call each protected route with no Authorization header and with a spoofed X-Internal: 1 header; both must return 401. Call it with a valid Bearer token and confirm 200.
- No scanner checked here detects a route that lacks an authentication dependency; the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python, p/owasp-top-ten, p/security-audit, p/fastapi | No findings on any implementation. No rule can know which routes are meant to require authentication, so a route with no check, or one that trusts a client header, looks like any other route.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/fastapi --exclude tests . |
| Bandit | B105hardcoded_password_string | One finding in every mode, LOW severity: OPERATOR_TOKEN in service.py, the demo credential the tests use. It is a fair flag -- in production the credential belongs in a secret store -- but it says nothing about whether the routes check it. No Bandit plugin detects missing authentication.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5) |
| fastapi | 0.142.2 |
| starlette | 1.7.0 |
| hmac, secrets (standard library) | Python 3.14.8 |
| httpx2 (TestClient transport, test only) | 2.13.1 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authentication/python-fastapifolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authentication/python-fastapi runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- fastapi 0.142.2
- starlette 1.7.0
- pydantic 2.13.5
- httpx2 2.13.1
- pytest 9.1.1
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 6 of 9 tests fail (5 exploit tests); wrong: 5 of 9 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 9/9 pass; IMPL=vulnerable fails 6/9 (5 exploit: both no-auth calls, both spoofed-header calls, the invalid token; plus the missing-token functional assertion); IMPL=wrong fails 5/9 (2 exploit: the two spoofed-header calls; plus the valid-token and missing-token functional assertions).
- Dependency freshness
- fastapi 0.142.2 (2026-09-30), starlette 1.7.0 (2026-09-23), pydantic 2.13.5 (2026-08-28) are pinned; fastapi 0.143.0 (2026-10-08) changed OpenTelemetry defaults and pydantic 2.14.x were under a week old, so the card holds the prior line, matching the other FastAPI cards. Python 3.14.8 is the 30 September 2026 security release.
- Scanners
- Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); no CodeQL query covers this weakness.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- CWE-306: Missing Authentication for Critical Function
- FastAPI: Security (HTTPBearer, dependencies)
- OWASP Authentication Cheat Sheet
- OWASP ASVS 5.0.0 V13 Configuration (13.2.1 authenticated backend communication)
- Python hmac.compare_digest (constant-time comparison)
Attack patterns: