Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix missing authentication in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Flask 3.1.3
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 17 KB
On this page

Steps

  1. 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.
  2. Add an authentication check in front of each protected route: read the Authorization header, require the Bearer scheme and a non-empty token.
  3. Compare the presented token to the server-side credential with hmac.compare_digest so the check is constant time.
  4. Answer 401 with a WWW-Authenticate: Bearer header when authentication is missing or fails; never fall back to "allow".
  5. Ignore client-supplied hints about who the caller is -- X-Internal, X-Forwarded-For, the Host header -- because the client writes them.
  6. Add regression tests that call each protected endpoint with no credential and with a spoofed internal header, and assert 401.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
@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

Fixedimpl/fixed.pyPython
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

Wrong fiximpl/wrong.pyPython
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 regression test

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.

Testtests/test_auth.pyPython
# 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

Pitfalls

  • "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).

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix missing authentication in Python and Flask
ToolRuleWhat it found here
Semgrepp/python, p/owasp-top-ten, p/security-audit, p/flaskNo 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 .
BanditB105hardcoded_password_stringOne 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
hmac, secrets (standard library)Python 3.14.8
pytest9.1.1

Run the tests

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.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 17 KB

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

  1. CWE-306: Missing Authentication for Critical Function
  2. OWASP Authentication Cheat Sheet
  3. OWASP ASVS 5.0.0 V13 Configuration (13.2.1 authenticated backend communication)
  4. Flask: Request headers (request.headers)
  5. Python hmac.compare_digest (constant-time comparison)
  6. MDN: WWW-Authenticate

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.