19.9. Testing Authentication
Table of Contents
Why Testing Authentication Is Special
Authentication code is different from most other code in your backend:
- It is security critical. A small bug can expose user accounts.
- It often has many edge cases such as expired tokens, wrong passwords, locked accounts.
- It usually interacts with several layers at once: database, hashing, tokens, email, sessions.
Because of this, you want tests that are:
- Very explicit about what is allowed and what is forbidden.
- Covering both the happy paths and the attack-like scenarios.
In this chapter we focus on what is unique to testing authentication, not on writing tests in general or building auth itself.
Typical Authentication Flows To Test
Before writing tests you should list the flows you support. Example flows:
- Registration and email verification
- Login with password
- Login with tokens or sessions
- Logout
- Password reset
- Refresh token rotation
For each flow, think in terms of:
- What must succeed: valid input, valid credentials, valid tokens.
- What must fail: wrong password, invalid token, expired token, missing permissions.
You can capture this as a checklist. For example, for a simple login endpoint:
| Scenario type | Example scenario |
|---|---|
| Success | Correct email and password returns access token |
| Failure | Wrong password returns 401, no token |
| Failure | Unknown email returns 401, no token |
| Failure | Locked/disabled user cannot log in |
| Security | Same error message for wrong email and wrong password |
You will later convert these scenarios into automated tests.
Isolating Authentication Logic
Authentication usually has:
- Pure logic: verify password, build JWT payload, check token claims.
- Side effects: database queries, sending emails, storing sessions, hitting cache.
Pure logic is easiest to test because you do not need a database or HTTP client.
Examples of pure functions you should test in isolation:
verify_password(plain, hashed) -> boolhash_password(plain) -> hashedcreate_access_token(user_id, expires_in)decode_token(token) -> payload or raises erroruser_can_login(user)(checks flags likeis_active)
You test these like any other function, for example in Python style:
def test_verify_password_correct():
hashed = hash_password("secret123")
assert verify_password("secret123", hashed) is True
def test_verify_password_wrong():
hashed = hash_password("secret123")
assert verify_password("wrong", hashed) is False
Important rule: Keep as much auth logic as possible in small, testable functions.
Only the minimal glue should live inside your web framework handlers.
If you mix database calls, HTTP responses, and cryptography in a single function, it becomes hard to test and easy to make security mistakes.
Testing Registration and Login Endpoints
Once the core logic is tested, you test the HTTP endpoints that expose it.
Here we do integration-style tests: call the API, assert the HTTP response and important side effects.
Registration
Typical things to test:
- Can register with valid data, returns correct status code.
- Cannot register with invalid data (bad email, short password, missing fields).
- Cannot register with an email that already exists.
- Password is not returned in response.
- Password is stored hashed, not in plain text.
Example structure:
def test_register_success(client, db_session):
payload = {
"email": "user@example.com",
"password": "StrongPass123",
}
response = client.post("/register", json=payload)
assert response.status_code == 201
data = response.json()
# Response does not leak password
assert "password" not in data
# Database has the new user with hashed password
user = db_session.query(User).filter_by(email="user@example.com").one()
assert user.email == "user@example.com"
assert user.password_hash != "StrongPass123"And a negative case:
def test_register_existing_email(client, user_factory):
existing = user_factory(email="user@example.com")
payload = {"email": existing.email, "password": "AnotherPass123"}
response = client.post("/register", json=payload)
assert response.status_code == 400 or response.status_code == 409The exact status code is your design, but tests must enforce it consistently.
Login
For login you mainly test:
- Correct credentials produce a token or session cookie.
- Wrong credentials never produce auth credentials.
- Account state (for example
is_active == False) blocks login. - Rate limiting or lockout behavior if you have it.
Example pattern:
def test_login_success(client, user_factory):
user = user_factory(password="Password123")
response = client.post("/login", json={
"email": user.email,
"password": "Password123"
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"Negative tests:
def test_login_wrong_password(client, user_factory):
user = user_factory(password="Password123")
response = client.post("/login", json={
"email": user.email,
"password": "WrongPassword"
})
assert response.status_code == 401
body = response.json()
# Message should not reveal whether email exists
assert "invalid credentials" in body["detail"].lower()
Important rule: Error messages for login should not reveal whether the email or username exists.
Always test that you use a generic message such as “Invalid credentials”.
Testing Protected Endpoints
Protected endpoints require a valid session or token. This is where you verify that your auth mechanism actually guards your resources.
You should at least test:
- Access with no auth header is rejected.
- Access with invalid token is rejected.
- Access with expired token is rejected.
- Access with valid token is allowed.
- Access with insufficient permissions is rejected.
To simplify tests, create helpers:
def auth_header_for(user) -> dict:
token = create_access_token(user_id=user.id)
return {"Authorization": f"Bearer {token}"}Then use it:
def test_protected_requires_auth(client):
response = client.get("/me")
assert response.status_code == 401
def test_protected_with_valid_token(client, user_factory):
user = user_factory()
headers = auth_header_for(user)
response = client.get("/me", headers=headers)
assert response.status_code == 200
body = response.json()
assert body["id"] == user.idFor permission checks:
def test_admin_endpoint_rejects_regular_user(client, user_factory):
user = user_factory(is_admin=False)
headers = auth_header_for(user)
response = client.get("/admin/stats", headers=headers)
assert response.status_code == 403This kind of test confirms that your authorization logic is wired correctly, not only the token structure.
Testing Tokens and Sessions
Your application might use:
- Stateless tokens such as JWTs.
- Stateful sessions stored in a database or cache.
You must ensure that both the creation and the validation behave correctly.
Testing JWT-like Tokens
Pure logic tests:
def test_create_and_decode_token_roundtrip():
token = create_access_token(user_id=123, expires_in=3600)
payload = decode_token(token)
assert payload["sub"] == "123"Expiration tests:
def test_expired_token_is_rejected():
token = create_access_token(user_id=123, expires_in=0)
with pytest.raises(TokenExpiredError):
decode_token(token)Or at API level:
def test_expired_token_cannot_access_protected(client, user_factory):
user = user_factory()
token = create_access_token(user_id=user.id, expires_in=0)
headers = {"Authorization": f"Bearer {token}"}
response = client.get("/me", headers=headers)
assert response.status_code == 401
If you add fields like aud, iss, or scope, write tests that ensure invalid values are refused.
Important rule: Every security relevant claim in your tokens must have a test that proves incorrect values are rejected.
Testing Sessions
If you use server-side sessions:
- Test that login creates a session (for example cookie is set).
- Test that logout clears or invalidates the session.
- Test that manually deleted sessions result in 401 on next request.
Example structure:
def test_login_sets_session_cookie(client, user_factory):
user = user_factory(password="Password123")
response = client.post("/login", json={
"email": user.email,
"password": "Password123"
})
assert response.status_code == 200
assert "set-cookie" in response.headersAnd logout:
def test_logout_invalidates_session(client, logged_in_client):
response = logged_in_client.post("/logout")
assert response.status_code == 200
# After logout, protected routes should fail
response2 = logged_in_client.get("/me")
assert response2.status_code == 401
Here logged_in_client can be a test fixture that performs login for you.
Negative and Security Edge Case Tests
For authentication you should invest in many negative tests, not only success cases.
Examples of important negative tests:
- Missing fields in JSON, for example no password.
- Password too short according to your policy.
- Email with incorrect format, if you validate it.
- Modified token signature.
- Token signed with wrong key.
- Token that uses unsupported algorithm field.
- Token with user id that does not exist anymore.
- Token for a user that is disabled or deleted.
- Old refresh token that should have been rotated out.
You might test token tampering like this:
def test_token_with_invalid_signature_is_rejected():
token = "header.payload.invalidsignature"
with pytest.raises(TokenSignatureError):
decode_token(token)Or as an API test:
def test_tampered_token_cannot_access_protected(client, user_factory):
user = user_factory()
valid_token = create_access_token(user_id=user.id)
tampered = valid_token[:-1] + ("a" if valid_token[-1] != "a" else "b")
response = client.get("/me", headers={
"Authorization": f"Bearer {tampered}"
})
assert response.status_code == 401Important rule: For every “this should never happen in production” case, write at least one test that shows the system fails safely instead of failing open.
Testing Rate Limiting and Lockouts (If Implemented)
If you implement rate limiting or account lockout to slow down brute-force attacks, you should test the logic.
For a simple “lock account after 5 failed logins” algorithm:
def test_account_locks_after_too_many_failed_logins(client, user_factory):
user = user_factory(password="Password123")
for _ in range(5):
response = client.post("/login", json={
"email": user.email,
"password": "WrongPassword"
})
assert response.status_code == 401
# One more attempt with correct password still fails
response = client.post("/login", json={
"email": user.email,
"password": "Password123"
})
assert response.status_code in (401, 423)You may also test unlocking after a certain time or manual admin action, depending on your design.
Test Data and Fixtures for Authentication
Repeated setup can become messy, so create fixtures or helpers that are specific to auth.
Typical examples:
user_factorythat creates users with hashed passwords.admin_user_factoryfor privileged users.auth_header_for(user)that returns a ready Authorization header.logged_in_clientthat acts as a client already logged in.
Minimal example pattern:
@pytest.fixture
def user_factory(db_session):
def create_user(email="user@example.com", password="Password123", is_admin=False):
hashed = hash_password(password)
user = User(email=email, password_hash=hashed, is_admin=is_admin)
db_session.add(user)
db_session.commit()
return user
return create_userWith these helpers, your tests become short and focused on the behavior, not on boilerplate.
Keeping Authentication Tests Maintainable
Over time you will add more authentication features. To keep the test suite useful:
- Put auth tests in clear modules, for example:
test_registration.pytest_login.pytest_tokens.pytest_protected_routes.py- Use helper functions for repeated patterns such as “make request and assert 401”.
- Keep test descriptions clear, for example
test_inactive_user_cannot_loginrather than a vague name.
Finally, treat auth tests as non optional. Whenever you change authentication logic, update or add tests first. This helps you avoid introducing subtle security regressions as your backend grows.
Views: 8
KAHIBARO