Skip to content

Session Management

This chapter covers the session-based authentication pattern used between your React frontend and FastAPI backend. The core idea: the frontend sends the Gaia API key once during login, the backend stores it server-side and returns a session ID. All subsequent requests use only the session ID.


Why Session-Based Auth?

Text Only
┌──────────┐                    ┌──────────────┐                    ┌───────────┐
│ Frontend │  POST /login       │   Backend    │                    │  Gaia API │
│          │  { api_key: "…" }  │              │                    │           │
│          │───────────────────▶│  Validate    │───────────────────▶│  /datasets│
│          │                    │  key against │◀───────────────────│  (200 OK) │
│          │  { session_id }    │  Gaia, store │                    │           │
│          │◀───────────────────│  key in      │                    │           │
│          │                    │  memory/DB   │                    │           │
│          │                    │              │                    │           │
│          │  GET /datasets     │              │                    │           │
│          │  X-Session-ID: …   │  Look up key │                    │           │
│          │───────────────────▶│  from session│───────────────────▶│  /datasets│
│          │                    │  Attach to   │◀───────────────────│           │
│          │  { datasets: [] }  │  Gaia call   │                    │           │
│          │◀───────────────────│              │                    │           │
└──────────┘                    └──────────────┘                    └───────────┘

Security benefit

The API key never leaves the server after login. The frontend only stores a session ID — an opaque token that can't be used to call Gaia directly. If a session ID is stolen, you can invalidate it without rotating the API key.

Advantages:

  • API key isolation — The key is stored server-side; the browser never retains it.
  • Revocability — Sessions can be invalidated individually without affecting the underlying key.
  • Simplicity — No JWTs to parse, no refresh tokens, no token rotation logic.

Trade-offs:

  • Server-side state is required (in-memory dict, Redis, or database).
  • Horizontal scaling requires a shared session store (Redis, DB).

Backend Implementation

Session Store

The session store maps session IDs to API keys with TTL-based expiration.

storage/session_store.py

Python
"""In-memory session store with TTL expiration."""

from __future__ import annotations

import time
import uuid
import threading
from dataclasses import dataclass, field


@dataclass
class SessionEntry:
    api_key: str
    created_at: float = field(default_factory=time.time)
    last_accessed: float = field(default_factory=time.time)


class SessionStore:
    """Thread-safe in-memory session store with TTL."""

    def __init__(self, ttl_seconds: int = 3600):
        self._sessions: dict[str, SessionEntry] = {}
        self._ttl = ttl_seconds
        self._lock = threading.Lock()

    def create(self, api_key: str) -> str:
        """Create a new session and return its ID."""
        session_id = str(uuid.uuid4())
        with self._lock:
            self._sessions[session_id] = SessionEntry(api_key=api_key)
        return session_id

    def get_api_key(self, session_id: str) -> str | None:
        """Look up the API key for a session, or None if expired/missing."""
        with self._lock:
            entry = self._sessions.get(session_id)
            if not entry:
                return None
            if time.time() - entry.last_accessed > self._ttl:
                del self._sessions[session_id]
                return None
            entry.last_accessed = time.time()
            return entry.api_key

    def delete(self, session_id: str) -> None:
        """Delete a session by ID."""
        with self._lock:
            self._sessions.pop(session_id, None)

    def delete_by_api_key(self, api_key: str) -> None:
        """Delete all sessions associated with an API key."""
        with self._lock:
            to_remove = [
                sid for sid, entry in self._sessions.items()
                if entry.api_key == api_key
            ]
            for sid in to_remove:
                del self._sessions[sid]

    def cleanup_expired(self) -> int:
        """Remove expired sessions. Returns the number of sessions removed."""
        now = time.time()
        with self._lock:
            expired = [
                sid for sid, entry in self._sessions.items()
                if now - entry.last_accessed > self._ttl
            ]
            for sid in expired:
                del self._sessions[sid]
            return len(expired)

    @property
    def active_count(self) -> int:
        """Return the number of active (non-expired) sessions."""
        now = time.time()
        with self._lock:
            return sum(
                1 for entry in self._sessions.values()
                if now - entry.last_accessed <= self._ttl
            )


# Module-level singleton
session_store = SessionStore()

In-memory limitations

The in-memory store is lost when the process restarts and doesn't work with multiple workers or containers. For production, swap in a Redis-backed or SQLite-backed implementation with the same interface.

SQLite Alternative

For persistence across restarts:

Python
"""SQLite-backed session store."""

import sqlite3
import time
import uuid
import threading


class SqliteSessionStore:
    def __init__(self, db_path: str = "sessions.db", ttl_seconds: int = 3600):
        self._db_path = db_path
        self._ttl = ttl_seconds
        self._lock = threading.Lock()
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self._db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS sessions (
                    session_id TEXT PRIMARY KEY,
                    api_key TEXT NOT NULL,
                    created_at REAL NOT NULL,
                    last_accessed REAL NOT NULL
                )
            """)

    def create(self, api_key: str) -> str:
        session_id = str(uuid.uuid4())
        now = time.time()
        with self._lock, sqlite3.connect(self._db_path) as conn:
            conn.execute(
                "INSERT INTO sessions VALUES (?, ?, ?, ?)",
                (session_id, api_key, now, now),
            )
        return session_id

    def get_api_key(self, session_id: str) -> str | None:
        with self._lock, sqlite3.connect(self._db_path) as conn:
            row = conn.execute(
                "SELECT api_key, last_accessed FROM sessions WHERE session_id = ?",
                (session_id,),
            ).fetchone()
            if not row:
                return None
            api_key, last_accessed = row
            if time.time() - last_accessed > self._ttl:
                conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
                return None
            conn.execute(
                "UPDATE sessions SET last_accessed = ? WHERE session_id = ?",
                (time.time(), session_id),
            )
            return api_key

    def delete(self, session_id: str) -> None:
        with self._lock, sqlite3.connect(self._db_path) as conn:
            conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))

    def cleanup_expired(self) -> int:
        cutoff = time.time() - self._ttl
        with self._lock, sqlite3.connect(self._db_path) as conn:
            cursor = conn.execute(
                "DELETE FROM sessions WHERE last_accessed < ?", (cutoff,)
            )
            return cursor.rowcount

Login Endpoint

The login endpoint validates the API key by making a test call to Gaia, then creates a session.

Python
@router.post("/login", response_model=LoginResponse, tags=["Auth"])
async def login(request: LoginRequest):
    """Validate the API key against Gaia and create a session."""
    async with GaiaClient(api_key=request.api_key) as client:
        try:
            await client.list_datasets()
        except GaiaAuthError:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid API key.",
            )
        except GaiaError as e:
            raise HTTPException(
                status_code=status.HTTP_502_BAD_GATEWAY,
                detail=f"Could not validate key with Gaia: {e}",
            )

    session_id = session_store.create(api_key=request.api_key)
    return LoginResponse(session_id=session_id)

Key validation strategy

We call list_datasets() as a lightweight validation — if the key is invalid, Gaia returns a 401. You could also use a dedicated health or whoami endpoint if one exists.


Session Cleanup

Expired sessions accumulate in memory. Run a periodic cleanup task during the application lifespan:

Python
import asyncio
import contextlib

from backend.storage.session_store import session_store


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    """Run periodic session cleanup as a background task."""

    async def cleanup_loop():
        while True:
            await asyncio.sleep(300)  # every 5 minutes
            removed = session_store.cleanup_expired()
            if removed:
                logger.info("Cleaned up %d expired sessions", removed)

    task = asyncio.create_task(cleanup_loop())
    yield
    task.cancel()

Frontend Implementation

Login Flow

  1. User enters their API key in the LoginForm component.
  2. Frontend calls POST /api/v1/login with { api_key: "..." }.
  3. Backend validates the key, returns { session_id: "..." }.
  4. Frontend stores the session ID in localStorage and in the Zustand session store.
  5. All subsequent API calls include X-Session-ID in the request header.

Storing the Session ID

TypeScript
// On successful login
const { session_id } = await apiLogin(apiKey);
setSessionId(session_id);                    // in-memory (api/client.ts)
localStorage.setItem("sessionId", session_id); // persistent

// On app startup — restore from localStorage
const saved = localStorage.getItem("sessionId");
if (saved) {
  setSessionId(saved);
  restoreSession(saved);
}

Attaching to Requests

The api/client.ts module automatically attaches the session ID to every request:

TypeScript
if (sessionId) {
  headers["X-Session-ID"] = sessionId;
}

No per-call boilerplate is needed.


Handling Session Expiry

When a session expires, the backend returns a 401. The frontend should detect this and redirect to login.

Backend: Return 401 for Expired Sessions

This is already handled by the get_session_api_key dependency:

Python
def get_session_api_key(session_id: str = Depends(get_session_id)) -> str:
    api_key = session_store.get_api_key(session_id)
    if not api_key:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired session.",
        )
    return api_key

Frontend: Detect 401 and Redirect

Add a global response interceptor to the API client:

TypeScript
// src/api/client.ts — inside the request function

if (response.status === 401) {
  // Clear local session state
  setSessionId(null);
  localStorage.removeItem("sessionId");

  // Redirect to login
  window.location.href = "/login";
  throw new ApiError(401, "Session expired. Please log in again.");
}

Toast notification

Instead of (or in addition to) a hard redirect, show a toast notification that the session expired. Libraries like react-hot-toast or sonner make this easy.


Session TTL Configuration

The session TTL is configurable via the SESSION_TTL_SECONDS environment variable:

Value Meaning
3600 (default) Sessions expire after 1 hour of inactivity
86400 Sessions last 24 hours
0 Sessions never expire (not recommended)

The TTL is based on last access time, not creation time. Every successful API call resets the timer.


Security Considerations

HTTPS in production

Session IDs are transmitted as HTTP headers. Without HTTPS, they can be intercepted. Always use TLS in production.

  • Session ID entropy — UUIDv4 provides 122 bits of randomness, which is sufficient for session tokens.
  • No API key in localStorage — Only the session ID is stored client-side. The API key is never persisted in the browser.
  • Logout clears everything — The logout endpoint deletes the server-side session, and the frontend clears localStorage.
  • Rate limit login attempts — Consider adding rate limiting to the /login endpoint to prevent brute-force attacks on API keys.

Next Steps