Session Management
Session management is where authentication ends and everything else begins. You verified the user is who they claim to be — now you need to carry that identity across every subsequent request without re-authenticating every time. The decisions you make here determine how long an attacker has if they steal a token, whether "log out" actually works, and whether a compromised refresh token can be used indefinitely.
These are not theoretical concerns. Session fixation, CSRF via missing SameSite, stolen JWTs that cannot be revoked — these are the failure modes that show up in real incident reports.
Cookie Attributes: The Mandatory Set
A session cookie without the right attributes is a vulnerability, not a feature. The required set:
| Attribute | Value | Why |
|---|---|---|
HttpOnly |
true | Prevents JavaScript access; blocks XSS token theft |
Secure |
true | Transmission over HTTPS only |
SameSite |
Lax or Strict |
CSRF mitigation |
Path |
/ |
Scope to application |
Max-Age / Expires |
Explicit | Controls idle expiry |
SameSite=Lax allows the cookie on top-level GET navigations (following a link from an email) but blocks cross-site POST — covers most cases. SameSite=Strict blocks even top-level navigations from other origins; use for high-security applications where users navigating from external links can always be prompted to log in.
# FastAPI — setting a session cookie correctly
from fastapi import Response
def set_session_cookie(response: Response, session_token: str, max_age: int = 3600):
response.set_cookie(
key="session",
value=session_token,
max_age=max_age, # seconds
httponly=True,
secure=True,
samesite="lax",
path="/",
domain=None, # let browser scope to current domain
)Never set Domain=.example.com unless you explicitly need subdomains to share the cookie — it widens the attack surface.
Token Design: Opaque vs JWT
Two common choices:
Opaque session tokens — a random string (128 bits minimum) mapped to session data in a server-side store (Redis, database). Revocation is instant: delete the record.
JWTs — signed tokens carrying claims in the payload. Stateless; no server-side store needed. Revocation requires a denylist or short expiry.
For most web applications, opaque tokens in HTTP-only cookies backed by Redis are simpler to revoke and harder to misuse. JWTs shine in stateless microservice environments where you want to avoid a Redis hop on every request — but you must manage expiry carefully.
# Opaque session with Redis
import secrets, redis, json
from datetime import datetime, timedelta, timezone
r = redis.Redis()
SESSION_TTL = 3600 # 1 hour
def create_session(user_id: str, roles: list[str]) -> str:
token = secrets.token_urlsafe(32) # 256 bits
session_data = {
"user_id": user_id,
"roles": roles,
"created_at": datetime.now(timezone.utc).isoformat(),
}
r.setex(f"session:{token}", SESSION_TTL, json.dumps(session_data))
return token
def get_session(token: str) -> dict | None:
data = r.get(f"session:{token}")
if data is None:
return None
# sliding expiry: reset TTL on each valid access
r.expire(f"session:{token}", SESSION_TTL)
return json.loads(data)
def revoke_session(token: str):
r.delete(f"session:{token}")Refresh Token Architecture
For APIs and mobile clients, a short-lived access token plus a longer-lived refresh token is the standard pattern.
Critical points in this flow:
Refresh token rotation — issue a new refresh token on every use and invalidate the old one. If an old refresh token is presented, invalidate the entire session (it was either replayed or stolen).
Refresh token storage — HTTP-only cookie for web clients; secure storage (Keychain/Keystore) for mobile. Never in localStorage.
def rotate_refresh_token(old_token: str, db, r) -> tuple[str, str] | None:
session = db.query(RefreshToken).filter_by(token=old_token).first()
if session is None:
return None # token never existed
if session.revoked:
# Token reuse detected — revoke entire family
db.query(RefreshToken).filter_by(family_id=session.family_id).update(
{"revoked": True}
)
db.commit()
return None # signal: force re-authentication
# Revoke old, issue new
session.revoked = True
new_refresh = secrets.token_urlsafe(32)
db.add(RefreshToken(
token=new_refresh,
user_id=session.user_id,
family_id=session.family_id, # same family for reuse detection
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
))
db.commit()
new_access = issue_access_token(session.user_id)
return new_access, new_refreshSession Fixation
Session fixation: an attacker sets a known session ID on the victim's browser before login. After the victim authenticates, the attacker uses the pre-set ID to access the authenticated session.
Mitigation is simple but often missed: always issue a new session token on login, regardless of whether a session token already exists.
def login(username: str, password: str, old_session_token: str | None, response: Response):
user = authenticate(username, password)
if user is None:
raise HTTPException(status_code=401)
# Invalidate any pre-existing session
if old_session_token:
revoke_session(old_session_token)
# Always issue a fresh token post-authentication
new_token = create_session(user.id, user.roles)
set_session_cookie(response, new_token)
return {"status": "ok"}Idle and Absolute Timeout
Two independent timeouts:
- Idle timeout: reset on each request. User inactive for 30 minutes → session expires.
- Absolute timeout: session expires N hours after creation regardless of activity. Prevents a session from living forever if the user leaves a browser tab open.
The sliding expiry in the Redis example above handles idle timeout. Absolute timeout requires storing created_at in the session and checking it on each access.
MAX_SESSION_AGE = timedelta(hours=8)
def validate_session(token: str) -> dict | None:
data = get_session(token) # also resets idle TTL
if data is None:
return None
created = datetime.fromisoformat(data["created_at"])
if datetime.now(timezone.utc) - created > MAX_SESSION_AGE:
revoke_session(token)
return None
return dataLogout That Actually Works
"Logout" that only clears the client cookie is not logout. The server-side session must be invalidated.
@router.post("/auth/logout")
async def logout(
response: Response,
session_token: str = Cookie(default=None)
):
if session_token:
revoke_session(session_token) # server-side invalidation
response.delete_cookie("session") # client-side cleanup
response.delete_cookie("refresh_token")
return {"status": "logged out"}For "log out everywhere" (terminate all sessions for a user), maintain a per-user set of session tokens in Redis and delete all of them.
Key Takeaways
- Set
HttpOnly,Secure, andSameSite=Laxon every session cookie — missing any one of these is a standalone vulnerability. - Opaque tokens backed by Redis are simpler to revoke than JWTs; prefer them for web applications unless you have a specific need for stateless tokens.
- Rotate refresh tokens on every use and treat re-use of a revoked token as a theft signal that invalidates the entire session family.
- Issue a new session token on every successful login to prevent session fixation attacks.
- Implement both idle and absolute timeouts independently — sliding expiry alone allows a session to live forever with continuous activity.
- Logout must invalidate the server-side session; clearing only the client cookie leaves the token valid and reusable for its remaining TTL.