Skip to main content
Security for Application Engineers

Session Management

Ravinder··6 min read
SecurityAppSecSession ManagementCookiesJWT
Share:
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.

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.

flowchart LR subgraph Opaque["Opaque Token Flow"] OC["Client\n(session cookie)"] -- "session=abc123" --> OS["Server"] OS -- "lookup abc123 in Redis" --> OR[("Redis\n{abc123: {userId, roles, exp}}")] OR -- "session data" --> OS end subgraph JWT["JWT Flow"] JC["Client\n(session cookie)"] -- "session=eyJ..." --> JS["Server"] JS -- "verify signature,\ndecode payload" --> JP["In-memory\n(no DB call)"] end

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.

sequenceDiagram participant Client participant AuthServer as Auth Server participant API Client->>AuthServer: POST /auth/login {credentials} AuthServer-->>Client: access_token (15 min) + refresh_token (30 days, httpOnly cookie) loop Normal requests Client->>API: GET /resource + Authorization: Bearer API-->>Client: 200 OK end Note over Client,API: access_token expires Client->>API: GET /resource + expired token API-->>Client: 401 Unauthorized Client->>AuthServer: POST /auth/refresh (refresh_token cookie sent automatically) AuthServer-->>Client: new access_token (15 min) + rotated refresh_token Client->>API: GET /resource + new access_token API-->>Client: 200 OK

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_refresh

Session 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 data

Logout 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, and SameSite=Lax on 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.
Share: