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

Fix missing authentication in Python and Django

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 header. Require an Authorization: Bearer token on each protected view, 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 Django 6.1.2
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 19 KB
On this page

Steps

  1. List the views that do something sensitive or change state. A critical function (here, rotating the signing key) must require authentication; a liveness probe can stay public.
  2. Write a decorator that authenticates the operator and wrap each protected view with it (alongside require_GET/require_POST).
  3. Read the Authorization header, require the Bearer scheme and compare the token to the server-side credential with hmac.compare_digest.
  4. Return JsonResponse(status=401, headers={"WWW-Authenticate": "Bearer"}) when the credential is missing or fails.
  5. Ignore client-supplied hints about the caller (X-Internal, X-Forwarded-For, Host); the client writes them, so prefer request.user from Django's auth, or a real credential, over anything the request asserts about itself.
  6. Add regression tests that call each protected view 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

Vulnerableops/impl/vulnerable.pyPython
@require_GET
def metrics(request):
    # No check: the caller is trusted because the route is "internal".
    return JsonResponse(service.metrics())


@require_POST
def rotate_key(request):
    return JsonResponse({"keyId": service.rotate_key()})

With IMPL=vulnerable, 6 of 9 tests fail (5 exploit tests).

The fix: require an authenticated operator

Fixedops/impl/fixed.pyPython
def require_operator(view):
    @wraps(view)
    def wrapped(request, *args, **kwargs):
        scheme, _, token = request.headers.get("Authorization", "").partition(" ")
        if scheme != "Bearer" or not token or not service.valid_operator_token(token):
            return JsonResponse(
                {"error": "operator authentication required"},
                status=401,
                headers={"WWW-Authenticate": "Bearer"},
            )
        return view(request, *args, **kwargs)

    return wrapped


@require_GET
@require_operator
def metrics(request):
    return JsonResponse(service.metrics())


@require_POST
@require_operator
def rotate_key(request):
    return JsonResponse({"keyId": service.rotate_key()})

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixops/impl/wrong.pyPython
def require_internal(view):
    @wraps(view)
    def wrapped(request, *args, **kwargs):
        # A client-controlled header is not a credential: anyone can send it.
        if request.headers.get("X-Internal") != "1":
            return JsonResponse({"error": "internal callers only"}, status=401)
        return view(request, *args, **kwargs)

    return wrapped


@require_GET
@require_internal
def metrics(request):
    return JsonResponse(service.metrics())


@require_POST
@require_internal
def rotate_key(request):
    return JsonResponse({"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):
    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: a reachable view 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 check on it authenticates nobody; only a value your own trusted proxy sets and strips can be relied on, and Django's own authentication (request.user) or a real token is better.
  • Comparing tokens with == leaks timing; use hmac.compare_digest.
  • Django's SecurityMiddleware, ALLOWED_HOSTS and CSRF are not authentication; a view with none of login_required, a permission check, or a token check answers everyone.
  • A decorator defined but not applied to the view does nothing; confirm the auth decorator actually wraps the handler, and watch decorator order (method guards and the auth check both need to run).
  • 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-django does this for you).
  2. Against your own endpoint, call each protected view 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 view that lacks an authentication check; 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 Django
ToolRuleWhat it found here
Semgrepp/python, p/owasp-top-ten, p/security-audit, p/djangoNo 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/django --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
FrameworkDjango 6.1.2
django6.1.2
hmac, secrets (standard library)Python 3.14.8
pytest9.1.1
pytest-django4.14.0

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

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-django runs all three modes and checks the expected results.

Download the project ZIP, 19 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • django 6.1.2
  • pytest 9.1.1
  • pytest-django 4.14.0
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
django 6.1.2 (2026-10-06, a security release), pytest-django 4.14.0 (2026-08-10) and pytest 9.1.1 (2026-06-19) are the latest, matching the other Django 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

  1. CWE-306: Missing Authentication for Critical Function
  2. Django 6.1: Using the authentication system
  3. OWASP Authentication Cheat Sheet
  4. OWASP ASVS 5.0.0 V13 Configuration (13.2.1 authenticated backend communication)
  5. Python hmac.compare_digest (constant-time comparison)

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.