User Session Management Low-Level Design

User Session Management — Low-Level Design

A session management system authenticates users and maintains their login state across requests. It must handle session creation, validation, expiry, revocation, and multi-device management. This is asked at nearly every company — it underpins every authenticated product.

Core Data Model

Session
  id              TEXT PK            -- random 32-byte hex token
  user_id         BIGINT NOT NULL
  created_at      TIMESTAMPTZ NOT NULL
  expires_at      TIMESTAMPTZ NOT NULL
  last_active_at  TIMESTAMPTZ NOT NULL
  ip_address      INET
  user_agent      TEXT
  device_id       TEXT               -- stable per-device identifier
  revoked_at      TIMESTAMPTZ        -- null = active

-- Index for fast per-user session listing (account management page)
CREATE INDEX idx_sessions_user ON Session(user_id, created_at DESC)
  WHERE revoked_at IS NULL;

Session Creation on Login

import secrets

def create_session(user_id, ip_address, user_agent):
    # Cryptographically secure random token — not a UUID
    token = secrets.token_hex(32)  # 64-character hex string, 256 bits of entropy

    db.execute("""
        INSERT INTO Session
          (id, user_id, created_at, expires_at, last_active_at, ip_address, user_agent)
        VALUES
          (%(token)s, %(uid)s, NOW(), NOW() + INTERVAL '30 days',
           NOW(), %(ip)s, %(ua)s)
    """, {'token': token, 'uid': user_id, 'ip': ip_address, 'ua': user_agent})

    # Cache in Redis for fast validation (avoid DB hit on every request)
    redis.setex(f'session:{token}', 3600, json.dumps({
        'user_id': user_id,
        'expires_at': (now() + timedelta(days=30)).isoformat()
    }))

    return token

Session Validation on Each Request

def validate_session(token):
    # 1. Check Redis cache first
    cached = redis.get(f'session:{token}')
    if cached:
        data = json.loads(cached)
        if data['expires_at'] > now().isoformat():
            # Extend Redis TTL on activity (sliding window)
            redis.expire(f'session:{token}', 3600)
            return data['user_id']

    # 2. Cache miss or expired — check DB
    session = db.execute("""
        SELECT user_id, expires_at, revoked_at
        FROM Session
        WHERE id = %(token)s
    """, {'token': token}).first()

    if not session:
        raise InvalidSession('Session not found')
    if session.revoked_at is not None:
        raise InvalidSession('Session revoked')
    if session.expires_at < now():
        raise InvalidSession('Session expired')

    # Refresh cache
    redis.setex(f'session:{token}', 3600, json.dumps({
        'user_id': session.user_id,
        'expires_at': session.expires_at.isoformat()
    }))

    # Update last_active_at asynchronously (avoid write on every request)
    update_last_active_async(token)

    return session.user_id

Session Revocation

def revoke_session(token):
    """Logout: revoke a single session."""
    db.execute("""
        UPDATE Session SET revoked_at=NOW()
        WHERE id=%(token)s AND revoked_at IS NULL
    """, {'token': token})
    redis.delete(f'session:{token}')  # Immediate cache invalidation

def revoke_all_sessions(user_id, except_token=None):
    """Logout all devices."""
    db.execute("""
        UPDATE Session SET revoked_at=NOW()
        WHERE user_id=%(uid)s
          AND revoked_at IS NULL
          AND (%(except)s IS NULL OR id != %(except)s)
    """, {'uid': user_id, 'except': except_token})

    # Cannot efficiently invalidate all user's Redis keys without a user→sessions index
    # Option 1: store session IDs in a Redis set per user
    session_ids = redis.smembers(f'user_sessions:{user_id}')
    for sid in session_ids:
        redis.delete(f'session:{sid}')
    redis.delete(f'user_sessions:{user_id}')

JWT vs Opaque Tokens

Opaque tokens (recommended for most applications):
  + Revocation is immediate: delete from Redis/DB
  + No sensitive data in the token
  + Token size: 64 chars
  - Requires server-side lookup on every request

JWT (stateless tokens):
  + No server-side storage needed; validated by signature alone
  + Works well for microservices (no shared session store)
  - Revocation is hard: token is valid until expiry unless you maintain a denylist
    (which defeats the stateless benefit)
  - Access token expiry must be short (15 min) → requires refresh token flow
  - Sensitive data can be read if not encrypted (JWE vs JWS)

Use JWT for: short-lived API access tokens, service-to-service auth
Use opaque tokens for: user sessions where logout must be instant

Key Interview Points

  • Use secrets.token_hex(32), not UUID: UUID v4 has only 122 bits of entropy and is not designed for security tokens. A 32-byte random hex string from a CSPRNG has 256 bits — brute-force infeasible.
  • Redis TTL shorter than DB expiry: Redis TTL of 1 hour with DB expiry of 30 days provides fast validation on active sessions while ensuring inactive sessions expire from the cache automatically.
  • Async last_active_at update: Writing last_active_at on every authenticated request doubles your write load. Buffer updates and flush every 60 seconds, or use a sliding window with Redis EXPIRE.
  • Revocation requires cache invalidation: DELETE from Redis on logout. Without this, a revoked session remains valid in the cache for up to 1 hour — unacceptable for security-sensitive logout scenarios.

User session management and authentication design is discussed in Google system design interview questions.

Session management and login system design is covered in Meta system design interview preparation.

User session and multi-device authentication design is discussed in Netflix system design interview guide.

Scroll to Top