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.
Cookie Security Tests
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_cookieCSRF 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 == 200Session 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_clearToken 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.