Session Management Testing: Cookies, CSRF, Token Refresh, and Security

Session Management Testing: Cookies, CSRF, Token Refresh, and Security

Session management testing covers four areas: cookie security attributes (HttpOnly, Secure, SameSite), CSRF protection, token refresh behavior, and session lifecycle (creation, invalidation, concurrent sessions). These aren't glamorous tests, but session vulnerabilities — session fixation, missing CSRF protection, cookies over HTTP — are consistently in the OWASP Top 10. This guide covers what to test and how.

Key Takeaways

Test cookie security attributes programmatically. Don't rely on code review to catch missing HttpOnly or Secure flags — write tests that assert these attributes on the Set-Cookie header after login.

Test CSRF protection with actual forged requests. An effective CSRF test sends a state-changing request from a different origin (missing or wrong CSRF token) and verifies it's rejected. Tests that only check the token is present aren't sufficient.

Test session fixation prevention. After login, the session ID must change. If an attacker can fix a victim's pre-login session ID and then the victim logs in, the attacker can use that ID. Test that the session ID in the response differs from the one before login.

Test concurrent session limits if your app has them. If you allow only one active session per user, verify the first session is invalidated when a second login occurs. This is a common feature that's rarely tested.

Test that logout actually invalidates the session. Many logout implementations clear the cookie client-side but don't invalidate the server-side session. Test by capturing the session cookie, logging out, and verifying the captured cookie no longer grants access.

After a successful login, inspect the Set-Cookie headers:

# test_session_cookies.py
import pytest


def test_session_cookie_has_httponly_flag(client):
    """Session cookies must have HttpOnly to prevent XSS theft."""
    response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    assert response.status_code == 200
    
    # Find the session cookie
    cookies = response.headers.get_list("Set-Cookie")
    session_cookie = next(
        (c for c in cookies if "session" in c.lower() or "sessionid" in c.lower()),
        None,
    )
    assert session_cookie is not None, "No session cookie found"
    assert "HttpOnly" in session_cookie, f"HttpOnly flag missing from: {session_cookie}"


def test_session_cookie_has_secure_flag(client):
    """Session cookies must have Secure flag (HTTPS only)."""
    response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    cookies = response.headers.get_list("Set-Cookie")
    session_cookie = next(c for c in cookies if "session" in c.lower())
    
    assert "Secure" in session_cookie, "Secure flag missing — cookie sent over HTTP!"


def test_session_cookie_has_samesite_attribute(client):
    """Session cookies should have SameSite attribute."""
    response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    cookies = response.headers.get_list("Set-Cookie")
    session_cookie = next(c for c in cookies if "session" in c.lower())
    
    # SameSite=Strict or SameSite=Lax is acceptable
    assert "SameSite" in session_cookie, "SameSite attribute missing"
    assert "SameSite=None" not in session_cookie or "Secure" in session_cookie, \
        "SameSite=None requires Secure flag"


def test_session_cookie_has_path_set(client):
    """Session cookie path should be scoped, not open-ended."""
    response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    cookies = response.headers.get_list("Set-Cookie")
    session_cookie = next(c for c in cookies if "session" in c.lower())
    
    # Cookie should have Path set
    assert "Path=/" in session_cookie or "Path=/app" in session_cookie

CSRF Protection Tests

Test that state-changing endpoints reject requests without valid CSRF tokens:

# test_csrf_protection.py

def test_post_without_csrf_token_rejected(client):
    """State-changing requests without CSRF token must be rejected."""
    # Login to get an authenticated session
    login_response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    session_cookie = login_response.cookies.get("sessionid")
    
    # Make a state-changing request WITHOUT CSRF token
    response = client.post(
        "/api/account/update-email",
        json={"email": "attacker@evil.com"},
        cookies={"sessionid": session_cookie},
        # No X-CSRFToken header, no csrf_token in body
    )
    
    assert response.status_code in (403, 400), \
        "Request without CSRF token should be rejected!"


def test_post_with_wrong_csrf_token_rejected(client):
    """Requests with tampered CSRF token must be rejected."""
    login_response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    session_cookie = login_response.cookies.get("sessionid")
    
    response = client.post(
        "/api/account/update-email",
        json={"email": "attacker@evil.com"},
        cookies={"sessionid": session_cookie},
        headers={"X-CSRFToken": "tampered_csrf_value"},
    )
    
    assert response.status_code in (403, 400)


def test_post_with_valid_csrf_token_accepted(client):
    """Requests with valid CSRF token must succeed."""
    login_response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    session_cookie = login_response.cookies.get("sessionid")
    csrf_token = login_response.cookies.get("csrftoken")
    
    response = client.post(
        "/api/account/update-email",
        json={"email": "new_email@example.com"},
        cookies={"sessionid": session_cookie, "csrftoken": csrf_token},
        headers={"X-CSRFToken": csrf_token},
    )
    
    assert response.status_code == 200


def test_get_requests_dont_require_csrf(client):
    """GET requests should not require CSRF tokens (read-only operations)."""
    login_response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    session_cookie = login_response.cookies.get("sessionid")
    
    # GET without CSRF token
    response = client.get(
        "/api/profile",
        cookies={"sessionid": session_cookie},
    )
    
    assert response.status_code == 200

Session Fixation Tests

Session fixation: an attacker sets a known session ID before login. After the victim logs in with that session, the attacker uses it to access the account. Prevention: always generate a new session ID on login.

def test_session_id_changes_after_login(client):
    """Session ID must change after login to prevent session fixation."""
    # Get a pre-authentication session ID
    get_response = client.get("/auth/login")
    pre_login_session_id = get_response.cookies.get("sessionid")
    
    # Login
    login_response = client.post(
        "/auth/login",
        json={"email": "user@example.com", "password": "correct_password"},
        cookies={"sessionid": pre_login_session_id},
    )
    
    post_login_session_id = login_response.cookies.get("sessionid")
    
    # Session ID must change
    assert post_login_session_id != pre_login_session_id, \
        "Session ID did not change after login — session fixation vulnerability!"
    assert post_login_session_id is not None


def test_old_session_invalid_after_login(client):
    """Pre-login session ID must not work after login with a new session."""
    get_response = client.get("/auth/login")
    pre_login_session_id = get_response.cookies.get("sessionid")
    
    client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    
    # Try to use the old session ID
    response = client.get(
        "/api/profile",
        cookies={"sessionid": pre_login_session_id},
    )
    
    assert response.status_code in (401, 403), \
        "Pre-login session ID still works after login!"

Logout Tests

def test_session_invalidated_after_logout(client):
    """Session must be invalidated server-side on logout."""
    # Login and capture session cookie
    login_response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    session_cookie = login_response.cookies.get("sessionid")
    
    # Verify the session works
    profile_response = client.get(
        "/api/profile",
        cookies={"sessionid": session_cookie},
    )
    assert profile_response.status_code == 200
    
    # Logout
    logout_response = client.post(
        "/auth/logout",
        cookies={"sessionid": session_cookie},
    )
    assert logout_response.status_code in (200, 204, 302)
    
    # Try to use the captured session cookie after logout
    post_logout_response = client.get(
        "/api/profile",
        cookies={"sessionid": session_cookie},
    )
    
    assert post_logout_response.status_code in (401, 403), \
        "Session still valid after logout — server-side invalidation missing!"


def test_logout_clears_session_cookie(client):
    """Logout response should clear the session cookie."""
    login_response = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    session_cookie = login_response.cookies.get("sessionid")
    
    logout_response = client.post(
        "/auth/logout",
        cookies={"sessionid": session_cookie},
    )
    
    # Cookie should be cleared (max-age=0 or expires in the past)
    logout_cookies = logout_response.headers.get_list("Set-Cookie")
    session_clear = next(
        (c for c in logout_cookies if "sessionid" in c.lower()),
        None,
    )
    
    if session_clear:
        assert "Max-Age=0" in session_clear or "expires=Thu, 01 Jan 1970" in session_clear

Token Refresh Tests (JWT Sessions)

For apps using short-lived JWTs with refresh tokens:

from freezegun import freeze_time
from datetime import datetime, timedelta


def test_expired_access_token_refreshes_automatically(client, auth_tokens):
    access_token = auth_tokens["access_token"]
    refresh_token = auth_tokens["refresh_token"]
    
    # Fast-forward past access token expiry (assume 15-minute lifetime)
    with freeze_time(datetime.now() + timedelta(minutes=20)):
        response = client.get(
            "/api/profile",
            headers={"Authorization": f"Bearer {access_token}"},
            cookies={"refresh_token": refresh_token},
        )
    
    # App should transparently refresh and return 200
    assert response.status_code == 200
    # New access token should be in the response
    new_token = response.headers.get("X-New-Access-Token") or \
                response.cookies.get("access_token")
    assert new_token is not None
    assert new_token != access_token


def test_expired_refresh_token_returns_401(client, auth_tokens):
    access_token = auth_tokens["access_token"]
    refresh_token = auth_tokens["refresh_token"]
    
    # Fast-forward past refresh token expiry (assume 7-day lifetime)
    with freeze_time(datetime.now() + timedelta(days=8)):
        response = client.get(
            "/api/profile",
            headers={"Authorization": f"Bearer {access_token}"},
            cookies={"refresh_token": refresh_token},
        )
    
    assert response.status_code == 401


def test_refresh_token_single_use(client, auth_tokens):
    """Refresh tokens should be invalidated after use (rotation)."""
    refresh_token = auth_tokens["refresh_token"]
    
    # Use the refresh token once
    response1 = client.post(
        "/auth/token/refresh",
        json={"refresh_token": refresh_token},
    )
    assert response1.status_code == 200
    
    # Attempt to reuse the same refresh token
    response2 = client.post(
        "/auth/token/refresh",
        json={"refresh_token": refresh_token},
    )
    assert response2.status_code in (401, 400), \
        "Refresh token reuse allowed — rotation not implemented!"

Concurrent Session Tests

def test_concurrent_session_limit(client):
    """If app limits concurrent sessions, test the eviction behavior."""
    # Login on device 1
    login1 = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
        "device": "device1",
    })
    session1 = login1.cookies.get("sessionid")
    
    # Login on device 2 (if single-session limit, should invalidate device 1)
    login2 = client.post("/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
        "device": "device2",
    })
    session2 = login2.cookies.get("sessionid")
    
    # If your app allows only one active session:
    response1 = client.get("/api/profile", cookies={"sessionid": session1})
    response2 = client.get("/api/profile", cookies={"sessionid": session2})
    
    # One of these should be 401 (which one depends on your policy)
    statuses = {response1.status_code, response2.status_code}
    assert 401 in statuses or 403 in statuses, \
        "Both sessions still valid — concurrent session limit not enforced"

Session Security Checklist

Test Expected result
Session cookie HttpOnly Present
Session cookie Secure Present (HTTPS)
Session cookie SameSite Strict or Lax
POST without CSRF token 403
POST with wrong CSRF token 403
GET without CSRF token 200
Session ID changes on login Different ID after login
Pre-login session works after login 401/403
Session valid after logout 401/403
Logout clears cookie Max-Age=0 or expired
Refresh token rotation Second use fails
Expired refresh token 401

Summary

Session management bugs — missing CSRF protection, no server-side logout invalidation, session fixation — are consistently exploited in production. They're also straightforward to test: inspect Set-Cookie headers after login, attempt forged requests, check session validity after logout, verify session IDs regenerate. Write these tests once and run them in CI on every deploy.

Read more