KAHIBARO
Discord Login Register

19.9. Testing Authentication

Why Testing Authentication Is Special

Authentication code is different from most other code in your backend:

Because of this, you want tests that are:

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:

For each flow, think in terms of:

You can capture this as a checklist. For example, for a simple login endpoint:

Scenario typeExample scenario
SuccessCorrect email and password returns access token
FailureWrong password returns 401, no token
FailureUnknown email returns 401, no token
FailureLocked/disabled user cannot log in
SecuritySame 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 is easiest to test because you do not need a database or HTTP client.

Examples of pure functions you should test in isolation:

You test these like any other function, for example in Python style:

python
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:

Example structure:

python
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:

python
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 == 409

The exact status code is your design, but tests must enforce it consistently.

Login

For login you mainly test:

Example pattern:

python
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:

python
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:

To simplify tests, create helpers:

python
def auth_header_for(user) -> dict:
    token = create_access_token(user_id=user.id)
    return {"Authorization": f"Bearer {token}"}

Then use it:

python
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.id

For permission checks:

python
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 == 403

This kind of test confirms that your authorization logic is wired correctly, not only the token structure.


Testing Tokens and Sessions

Your application might use:

You must ensure that both the creation and the validation behave correctly.

Testing JWT-like Tokens

Pure logic tests:

python
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:

python
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:

python
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:

Example structure:

python
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.headers

And logout:

python
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:

You might test token tampering like this:

python
def test_token_with_invalid_signature_is_rejected():
    token = "header.payload.invalidsignature"
    with pytest.raises(TokenSignatureError):
        decode_token(token)

Or as an API test:

python
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 == 401

Important 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:

python
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:

Minimal example pattern:

python
@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_user

With 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:

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

Comments

Please login to add a comment.

Don't have an account? Register now!