API Testing · lesson 6 of 11
Also part of: Security Fundamentals Web Security (OWASP Top 10, fix-side) Ethical Hacking (methodology, lab-only)
Testing Bearer Tokens, Basic Auth, and 401 vs 403
Extend the local test API with two auth schemes and catch the assumption that 'wrong token' and 'no token' return the same status code — they don't, and mixing them up hides real bugs.
By SkillByExample · Published
Testing auth means testing three states, not two: no credentials, wrong credentials, and right credentials — and the first two often deserve different status codes.
Bearer tokens
Add two protected routes to the local API:
# local_api.py
import base64
import json
from http.server import BaseHTTPRequestHandler
VALID_TOKEN = "s3cret-token"
VALID_CREDENTIALS = ("admin", "hunter2")
class AuthAPIHandler(BaseHTTPRequestHandler):
def _send_json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/bearer/books":
self._handle_bearer()
return
if self.path == "/basic/books":
self._handle_basic()
return
self._send_json(404, {"error": "not found"})
def _handle_bearer(self):
auth = self.headers.get("Authorization")
if auth is None:
self._send_json(401, {"error": "missing Authorization header"})
return
if auth != f"Bearer {VALID_TOKEN}":
self._send_json(403, {"error": "invalid token"})
return
self._send_json(200, self.server.books)
def _handle_basic(self):
auth = self.headers.get("Authorization")
if auth is None or not auth.startswith("Basic "):
self._send_json(401, {"error": "missing credentials"})
return
decoded = base64.b64decode(auth.removeprefix("Basic ")).decode()
username, _, password = decoded.partition(":")
if (username, password) != VALID_CREDENTIALS:
self._send_json(403, {"error": "invalid credentials"})
return
self._send_json(200, self.server.books)
def log_message(self, format, *args):
pass
This server’s convention: no Authorization header at all is 401 (we don’t know who you
are); a header that’s present but wrong is 403 (we checked, and it’s not valid).
import requests
def test_missing_bearer_token_returns_401(local_api_url):
response = requests.get(f"{local_api_url}/bearer/books")
assert response.status_code == 401
def test_wrong_bearer_token_returns_403(local_api_url):
response = requests.get(f"{local_api_url}/bearer/books", headers={"Authorization": "Bearer wrong"})
assert response.status_code == 403
def test_correct_bearer_token_returns_200(local_api_url):
response = requests.get(f"{local_api_url}/bearer/books", headers={"Authorization": "Bearer s3cret-token"})
assert response.status_code == 200
assert response.json()[0]["title"] == "Dune"
test_bearer.py::test_missing_bearer_token_returns_401 PASSED [ 33%]
test_bearer.py::test_wrong_bearer_token_returns_403 PASSED [ 66%]
test_bearer.py::test_correct_bearer_token_returns_200 PASSED [100%]
============================== 3 passed in 1.69s ==============================
The bug: assuming “wrong” and “missing” are the same
def test_wrong_bearer_token_returns_401(local_api_url):
response = requests.get(f"{local_api_url}/bearer/books", headers={"Authorization": "Bearer wrong"})
assert response.status_code == 401
_____________________ test_wrong_bearer_token_returns_401 _____________________
local_api_url = 'http://127.0.0.1:56676'
def test_wrong_bearer_token_returns_401(local_api_url):
response = requests.get(f"{local_api_url}/bearer/books", headers={"Authorization": "Bearer wrong"})
> assert response.status_code == 401
E assert 403 == 401
E + where 403 = <Response [403]>.status_code
test_confusion_bug.py:6: AssertionError
The server did the right thing; the test’s expectation was wrong. “Unauthorized” is a misleading name — 401 is about authentication (who are you), 403 is about authorization (what are you allowed to do once we know). A wrong-but-present token has already answered “who are you” (incorrectly), which is why this server treats it as 403, not 401. Not every real API draws the line the same way — check the specific API’s docs rather than assuming.
Basic auth
import requests
from requests.auth import HTTPBasicAuth
def test_basic_auth_with_correct_credentials(local_api_url):
response = requests.get(f"{local_api_url}/basic/books", auth=HTTPBasicAuth("admin", "hunter2"))
assert response.status_code == 200
def test_basic_auth_with_wrong_password(local_api_url):
response = requests.get(f"{local_api_url}/basic/books", auth=HTTPBasicAuth("admin", "wrong"))
assert response.status_code == 403
def test_basic_auth_sends_base64_encoded_header(local_api_url):
response = requests.get(f"{local_api_url}/basic/books", auth=HTTPBasicAuth("admin", "hunter2"))
assert response.request.headers["Authorization"] == "Basic YWRtaW46aHVudGVyMg=="
test_basic.py::test_basic_auth_with_correct_credentials PASSED [ 33%]
test_basic.py::test_basic_auth_with_wrong_password PASSED [ 66%]
test_basic.py::test_basic_auth_sends_base64_encoded_header PASSED [100%]
============================== 3 passed in 1.65s ==============================
HTTPBasicAuth builds the header for you — "admin:hunter2" base64-encoded is
YWRtaW46aHVudGVyMg==, asserted directly on response.request.headers the same way the
encoding lesson checked Content-Type. Base64 is an encoding, not encryption; anyone who can
read that header can decode the credentials in one line, which is why Basic Auth belongs on
HTTPS only.
Practice
1. Assert that /basic/books with no credentials at all returns 401.
def test_missing_credentials_on_basic_returns_401(local_api_url):
response = requests.get(f"{local_api_url}/basic/books")
assert response.status_code == 401
test_practice.py::test_missing_credentials_on_basic_returns_401 PASSED [ 33%]
Same 401/403 split as the bearer routes: no Authorization header at all is 401, a rejected
one is 403.
2. Send the token with the wrong case ("bearer ..." instead of "Bearer ...") and check what happens.
def test_case_sensitive_token_is_rejected(local_api_url):
response = requests.get(f"{local_api_url}/bearer/books", headers={"Authorization": "bearer s3cret-token"})
assert response.status_code == 403
test_practice.py::test_case_sensitive_token_is_rejected PASSED [ 66%]
Header names are case-insensitive in HTTP, but header values are just strings, and this
server does an exact string comparison against "Bearer <token>". A client library that
lowercases scheme names before sending would fail against a server this strict.
3. Set auth once on a Session and reuse it across two requests.
def test_reusing_a_session_with_auth_set_once(local_api_url):
session = requests.Session()
session.auth = HTTPBasicAuth("admin", "hunter2")
first = session.get(f"{local_api_url}/basic/books")
second = session.get(f"{local_api_url}/basic/books")
assert first.status_code == 200
assert second.status_code == 200
test_practice.py::test_reusing_a_session_with_auth_set_once PASSED [100%]
session.auth attaches to every request made through that session, the same way
session.headers.update(...) did for the shared-session fixture two lessons back.
Next: mocking HTTP entirely — testing client code without a server, local or otherwise.