Authentication Testing
Authentication is the gatekeeper of your API. Testing it thoroughly means verifying not just that valid credentials work, but that invalid credentials fail correctly, tokens expire as expected, and sensitive information is never leaked in error responses.
Authentication Methods
| Method | How It Works | Common In |
|---|---|---|
| API Key | Static key in header or query param | Third-party integrations, internal APIs |
| OAuth 2.0 | Token exchange flow (authorization code, client credentials) | SaaS platforms, social login |
| JWT | Self-contained signed token with claims | Modern APIs, microservices |
| Session Cookie | Server-side session with cookie ID | Traditional web apps |
| Basic Auth | Base64-encoded username:password in header | Legacy APIs, simple internal tools |
Testing Each Method
API Key Testing
def test_valid_api_key(base_url):
r = requests.get(f"{base_url}/data",
headers={"X-API-Key": os.environ["VALID_API_KEY"]})
assert r.status_code == 200
def test_missing_api_key(base_url):
r = requests.get(f"{base_url}/data")
assert r.status_code == 401
def test_invalid_api_key(base_url):
r = requests.get(f"{base_url}/data",
headers={"X-API-Key": "invalid-key-12345"})
assert r.status_code == 401
# Should not reveal whether the key format is wrong vs not found
assert "valid key" not in r.text.lower()
def test_revoked_api_key(base_url, revoked_key):
r = requests.get(f"{base_url}/data",
headers={"X-API-Key": revoked_key})
assert r.status_code == 401
JWT Testing
def test_valid_jwt(base_url, valid_token):
r = requests.get(f"{base_url}/users/me",
headers={"Authorization": f"Bearer {valid_token}"})
assert r.status_code == 200
def test_expired_jwt(base_url, expired_token):
r = requests.get(f"{base_url}/users/me",
headers={"Authorization": f"Bearer {expired_token}"})
assert r.status_code == 401
def test_tampered_jwt(base_url, valid_token):
"""Modify the payload of a valid JWT — should be rejected."""
import base64
parts = valid_token.split(".")
# Decode payload, modify it, re-encode (signature will be invalid)
payload = base64.urlsafe_b64decode(parts[1] + "==")
tampered = valid_token.replace(parts[1], base64.urlsafe_b64encode(
payload.replace(b'"role":"viewer"', b'"role":"admin"')
).decode().rstrip("="))
r = requests.get(f"{base_url}/users/me",
headers={"Authorization": f"Bearer {tampered}"})
assert r.status_code == 401
def test_missing_auth_returns_401(base_url):
r = requests.get(f"{base_url}/users/me")
assert r.status_code == 401
assert "password" not in r.text.lower() # No info leakage
def test_malformed_auth_header(base_url):
r = requests.get(f"{base_url}/users/me",
headers={"Authorization": "NotBearer token123"})
assert r.status_code == 401
Token Refresh Flow
def test_token_refresh(base_url, refresh_token):
"""Refresh token should return a new access token."""
r = requests.post(f"{base_url}/auth/refresh",
json={"refresh_token": refresh_token})
assert r.status_code == 200
new_token = r.json()["access_token"]
assert new_token != ""
# New token should work
r = requests.get(f"{base_url}/users/me",
headers={"Authorization": f"Bearer {new_token}"})
assert r.status_code == 200
def test_used_refresh_token_invalidated(base_url, refresh_token):
"""After using a refresh token, it should be invalidated (one-time use)."""
r1 = requests.post(f"{base_url}/auth/refresh",
json={"refresh_token": refresh_token})
assert r1.status_code == 200
r2 = requests.post(f"{base_url}/auth/refresh",
json={"refresh_token": refresh_token})
assert r2.status_code == 401 # Reuse should fail
Session Cookie Testing
def test_session_cookie_attributes(base_url):
r = requests.post(f"{base_url}/auth/login", json={
"email": "test@example.com", "password": "pass123"
})
assert r.status_code == 200
# Verify cookie security attributes
session_cookie = r.cookies.get("session_id")
assert session_cookie is not None
# Check Set-Cookie header for attributes
set_cookie = r.headers.get("Set-Cookie", "")
assert "HttpOnly" in set_cookie # Not accessible via JavaScript
assert "Secure" in set_cookie # Only sent over HTTPS
assert "SameSite" in set_cookie # CSRF protection
Authorization Testing (RBAC)
Authentication verifies identity. Authorization verifies permissions. Test that users can only access what their role allows.
@pytest.mark.parametrize("role,endpoint,method,expected", [
("admin", "/users", "GET", 200),
("admin", "/users", "POST", 201),
("admin", "/users/1", "DELETE", 204),
("viewer", "/users", "GET", 200),
("viewer", "/users", "POST", 403),
("viewer", "/users/1", "DELETE", 403),
("editor", "/users", "GET", 200),
("editor", "/users", "POST", 201),
("editor", "/users/1", "DELETE", 403),
])
def test_role_based_access(base_url, get_token_for_role, role, endpoint, method, expected):
token = get_token_for_role(role)
r = requests.request(method, f"{base_url}{endpoint}",
headers={"Authorization": f"Bearer {token}"},
json={"name": "Test", "email": f"{role}@test.com"} if method == "POST" else None)
assert r.status_code == expected, \
f"Role {role} got {r.status_code} on {method} {endpoint}, expected {expected}"
Horizontal Authorization (IDOR)
Users should not access other users' data:
def test_user_cannot_access_other_users_data(base_url, user_a_token, user_b_id):
"""User A should not be able to view User B's private data."""
r = requests.get(f"{base_url}/users/{user_b_id}/private-data",
headers={"Authorization": f"Bearer {user_a_token}"})
assert r.status_code in (403, 404) # Either forbidden or not found
Security Checks
def test_auth_error_no_info_leakage(base_url):
"""Error responses should not reveal whether email exists."""
r1 = requests.post(f"{base_url}/auth/login",
json={"email": "exists@test.com", "password": "wrong"})
r2 = requests.post(f"{base_url}/auth/login",
json={"email": "nonexistent@test.com", "password": "wrong"})
# Both should return the same error message (no user enumeration)
assert r1.json().get("message") == r2.json().get("message")
def test_brute_force_protection(base_url):
"""Multiple failed logins should trigger rate limiting or lockout."""
for i in range(10):
r = requests.post(f"{base_url}/auth/login",
json={"email": "target@test.com", "password": f"wrong{i}"})
if r.status_code == 429:
break
assert r.status_code == 429 or r.status_code == 423 # Rate limited or locked
Practical Exercise
- Write tests for a login flow: valid credentials, invalid password, invalid email, empty fields
- Test JWT lifecycle: obtain token, use it, let it expire, verify 401, refresh it
- Test RBAC: create fixtures for admin, editor, and viewer roles; verify access matrix
- Test for IDOR: verify User A cannot access User B's data
- Test for information leakage: verify error messages do not reveal user existence
Key Takeaways
- Test every auth method: valid, missing, invalid, expired, and tampered credentials
- JWT tests should cover expiration, tampering, and refresh flows
- Authorization (RBAC) tests verify role-permission boundaries
- IDOR tests verify users cannot access other users' private data
- Error messages should not leak information (user enumeration, stack traces)
- Cookie security: verify HttpOnly, Secure, and SameSite attributes