Authenticate the operator, do not trust the network or a client-supplied header. Require an Authorization: Bearer token, compare it to the server-side credential with hmac.compare_digest, and answer 401 with WWW-Authenticate: Bearer when it is missing or wrong. Keep the health probe public; a critical function like key rotation must never run unauthenticated.
- List the endpoints that do something sensitive or change state. A critical function (here, rotating the signing key) must require authentication; a liveness probe with nothing sensitive in it can stay public.
- Add an authentication check in front of each protected route: read the Authorization header, require the Bearer scheme and a non-empty token.
- Compare the presented token to the server-side credential with hmac.compare_digest so the check is constant time.
- Answer 401 with a WWW-Authenticate: Bearer header when authentication is missing or fails; never fall back to "allow".
- Ignore client-supplied hints about who the caller is -- X-Internal, X-Forwarded-For, the Host header -- because the client writes them.
- Add regression tests that call each protected endpoint 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
@bp.get("/internal/metrics")
def metrics():
# No check: the caller is trusted because the route is "internal".
return jsonify(service.metrics())
@bp.post("/internal/rotate-key")
def rotate_key():
return jsonify({"keyId": service.rotate_key()})With IMPL=vulnerable, 6 of 9 tests fail (5 exploit tests).
The fix: require an authenticated operator
def require_operator(view):
@wraps(view)
def wrapped(*args, **kwargs):
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
if scheme != "Bearer" or not token or not service.valid_operator_token(token):
return jsonify({"error": "operator authentication required"}), 401, {"WWW-Authenticate": "Bearer"}
return view(*args, **kwargs)
return wrapped
@bp.get("/internal/metrics")
@require_operator
def metrics():
return jsonify(service.metrics())
@bp.post("/internal/rotate-key")
@require_operator
def rotate_key():
return jsonify({"keyId": service.rotate_key()})With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
def require_internal(view):
@wraps(view)
def wrapped(*args, **kwargs):
# A client-controlled header is not a credential: anyone can send it.
if request.headers.get("X-Internal") != "1":
return jsonify({"error": "internal callers only"}), 401
return view(*args, **kwargs)
return wrapped
@bp.get("/internal/metrics")
@require_internal
def metrics():
return jsonify(service.metrics())
@bp.post("/internal/rotate-key")
@require_internal
def rotate_key():
return jsonify({"keyId": service.rotate_key()})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):
# A client-supplied gateway header must not stand in for authentication.
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: an endpoint reachable over the network needs a credential, because network position (a VPN, a private subnet, a service mesh) can be bypassed, misconfigured or reached from a compromised neighbour.
- A client-supplied header such as X-Internal or X-Forwarded-For is set by the client, so trusting it authenticates nobody; only a value your own trusted proxy sets and strips from inbound requests can be relied on, and even then prefer a real credential.
- Comparing tokens with == leaks length and content through timing; use hmac.compare_digest.
- Hiding the route (an unguessable path, no link in the UI) is obscurity, not authentication; the endpoint still answers anyone who finds it.
- A GET endpoint that only reads is still sensitive if it returns data the caller should not see; authenticate reads as well as writes.
- 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-flask 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 check, because none can know which endpoints are meant to be protected; 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/flask | 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/flask --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 | Flask 3.1.3 |
| flask | 3.1.3 |
| hmac, secrets (standard library) | Python 3.14.8 |
| 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-flaskfolder.
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-flask 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
- flask 3.1.3
- werkzeug 3.1.9
- 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 6/9 fail (5 exploit: both no-auth calls, both spoofed-header calls, the invalid token; plus the missing-token functional assertion); IMPL=wrong 5/9 fail (2 exploit: the two spoofed-header calls; plus the valid-token and missing-token functional assertions, because a header is not a credential).
- Dependency freshness
- flask 3.1.3 (2026-02-19), werkzeug 3.1.9 (2026-09-27, a security release) and pytest 9.1.1 (2026-06-19) are the latest. 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
- OWASP Authentication Cheat Sheet
- OWASP ASVS 5.0.0 V13 Configuration (13.2.1 authenticated backend communication)
- Flask: Request headers (request.headers)
- Python hmac.compare_digest (constant-time comparison)
- MDN: WWW-Authenticate
Attack patterns: