Skip to main content
API Testing · lesson 6 of 11
API Testing intermediate 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

api-testing python authentication requests pytest

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.

Frequently Asked Questions

What's the actual difference between 401 and 403?
401 Unauthorized means the request has no valid credentials at all — the server doesn't know who's asking. 403 Forbidden means the server does know who's asking (credentials were present and parsed) but that identity isn't allowed to do this. 'Unauthorized' is a confusing name for a code about missing authentication rather than missing authorization, which is where most of the mix-ups come from.
Do all real APIs agree on 401 vs 403 for a wrong token?
No — this is one of the most inconsistent corners of API design. Some return 401 for any invalid credential (missing or wrong), reserving 403 purely for 'valid identity, insufficient permission.' Treat this lesson's split as one common convention, not a universal rule, and always check the specific API's documented behavior rather than assuming.
How does requests.auth.HTTPBasicAuth actually work?
It builds the Authorization header for you: base64-encode 'username:password' and prefix it with 'Basic '. It is not encryption — anyone who can read the header can decode the credentials in one line — which is why Basic Auth should only ever be used over HTTPS.
Why test that a Bearer token comparison is case-sensitive?
Because HTTP headers themselves are case-insensitive by name (Authorization vs authorization), it's easy to assume their values are too. They aren't — 'Bearer' with a lowercase 'b' is a different string than 'Bearer', and a server that does a strict string comparison (as this one does) will reject it. Testing that catches an auth library that normalizes case unexpectedly.
Can I set auth once instead of passing it to every request?
Yes — set session.auth = HTTPBasicAuth(...) on a requests.Session and every request made through that session carries it automatically. This is the same pattern as the shared-session fixture from an earlier lesson, just with credentials attached instead of headers.