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.
- 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.
- Write a decorator that authenticates the operator and wrap each protected view with it (alongside require_GET/require_POST).
- Read the Authorization header, require the Bearer scheme and compare the token to the server-side credential with hmac.compare_digest.
- Return JsonResponse(status=401, headers={"WWW-Authenticate": "Bearer"}) when the credential is missing or fails.
- 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.
- Add regression tests that call each protected view 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
@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
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
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 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 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).
- 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).
- 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.
- No scanner checked here detects a view that lacks an authentication check; 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/django | 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/django --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 | Django 6.1.2 |
| django | 6.1.2 |
| hmac, secrets (standard library) | Python 3.14.8 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
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.
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-django 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
- 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
- CWE-306: Missing Authentication for Critical Function
- Django 6.1: Using the authentication system
- 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: