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?¶
┌──────────┐ ┌──────────────┐ ┌───────────┐
│ 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¶
"""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:
"""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.
@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:
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¶
- User enters their API key in the
LoginFormcomponent. - Frontend calls
POST /api/v1/loginwith{ api_key: "..." }. - Backend validates the key, returns
{ session_id: "..." }. - Frontend stores the session ID in
localStorageand in the Zustand session store. - All subsequent API calls include
X-Session-IDin the request header.
Storing the Session ID¶
// 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:
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:
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:
// 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
/loginendpoint to prevent brute-force attacks on API keys.
Next Steps¶
- Error Handling — Handle session errors gracefully in the UI.
- Backend with FastAPI — See how sessions fit into the full backend.
- Frontend with React — See the login flow in context.