URL session continuity (no cookies)¶
Goal: keep application state across a full browser reload (F5) without using the browser cookie jar.
Streamlit’s default st.session_state is tied to the live script session; a hard refresh often starts a new session. FluxLit exposes small helpers in fluxlit.url_session: an opaque query parameter (default fluxlit_sid) plus a server-side SessionStore.
Quick pattern¶
import streamlit as st
from fluxlit.url_session import (
InMemorySessionStore,
ensure_url_session,
hydrate_url_session,
persist_url_session,
)
# Single-process dev only — use Redis (or similar) in multi-worker / multi-replica prod.
if "_fluxlit_store" not in st.session_state:
st.session_state["_fluxlit_store"] = InMemorySessionStore()
store = st.session_state["_fluxlit_store"]
ensure_url_session(st, store)
hydrate_url_session(st, store)
# ... your app mutates st.session_state ...
persist_url_session(st, store)
Call hydrate_url_session early each run, then persist_url_session when values you care about change (or at the end of the run). The default merge policy uses session_state.setdefault so existing widget keys are not overwritten by stale store data on the first paint.
Multipage / st.navigation¶
Treat the query string as part of your public URL contract. Any link or st.switch_page target should preserve the same fluxlit_sid (or your configured name) so refresh on any page still resolves the same server-side blob.
Configuration¶
url_session_query_param (env: FLUXLIT_URL_SESSION_QUERY_PARAM) names the query key for your app and for gateway access log redaction (see Observability). Helpers accept an explicit param= argument if you prefer not to read settings in Streamlit.
Tests and CI¶
Set FLUXLIT_TESTS=1 for Streamlit AppTest / Pytest runs. In that mode,
ensure_url_session, hydrate_url_session, and persist_url_session no-op by
default so headless tests do not depend on browser query strings or reruns. Set
FLUXLIT_FORCE_URL_SESSION_IN_TESTS=1 for the rare test that intentionally covers
URL-session continuity. FLUXLIT_DISABLE_URL_SESSION=1 disables the helpers in any
environment.
Security¶
Extended guidance (URL session vs auth tokens, email links, JWTs in URLs, Referrer-Policy, debug logging): URL sessions, query tokens, and email links (security).
HTTPS in production: the token is effectively a bearer secret in the URL (bookmarks,
Referer, shared links, screenshots).TTL:
InMemorySessionStoresupports TTL; cap size withmax_entries.Logging: do not log raw tokens at INFO. The gateway structured log field
queryredactsfluxlit_sidand your configuredurl_session_query_param.
Multi-replica: InMemorySessionStore is per process. Behind multiple pods or VMs, use a shared SessionStore implementation or load-balancer affinity; see Deployment (Multi-replica operations checklist) and the runbook Multi-replica: new Streamlit session after refresh in Runbooks.
External store recipes¶
For multiple replicas, implement SessionStore with a
shared backend. Keep the implementation in your app or infrastructure package so
FluxLit core does not need to depend on Redis, SQL drivers, or cloud SDKs.
Runnable examples live in examples/session_stores/:
fluxlit run examples.session_stores.app:app --no-pidfile
The demo uses the stdlib SQLite store by default. Set
FLUXLIT_SESSION_SQLITE_PATH to choose the database file.
import json
from typing import Any
from fluxlit.config import JsonValue
from fluxlit.url_session import SessionStore
class RedisSessionStore(SessionStore):
def __init__(self, redis_client: Any, *, prefix: str = "fluxlit:sid:") -> None:
self.redis = redis_client
self.prefix = prefix
def _key(self, session_id: str) -> str:
return f"{self.prefix}{session_id}"
def get(self, session_id: str) -> dict[str, JsonValue] | None:
raw = self.redis.get(self._key(session_id))
if raw is None:
return None
return json.loads(raw)
def set(
self,
session_id: str,
data: dict[str, JsonValue],
*,
ttl_seconds: float | None = None,
) -> None:
payload = json.dumps(data)
if ttl_seconds is None:
self.redis.set(self._key(session_id), payload)
else:
self.redis.setex(self._key(session_id), int(ttl_seconds), payload)
def delete(self, session_id: str) -> None:
self.redis.delete(self._key(session_id))
Production notes:
Use a TTL and rotate session IDs after privilege changes.
Keep the URL token opaque; never encode user identity or permissions in it.
Add monitoring for store latency and errors because page hydration now depends on it.
Pair the shared store with rollout/drain guidance in Deployment; sticky sessions alone do not protect users when a replica restarts.
Multi-replica failure modes (shared SessionStore)¶
When Redis (or another networked store) backs SessionStore:
Cold Redis / timeouts: if
getfails or times out, helpers may start a new session id — users lose continuity until the store recovers. Monitor Redis latency and error rates; consider short timeouts plus user-visible “reconnecting” states.Partial writes: prefer a single JSON blob per sid (as in the sketch above) or use transactions so
hydratenever sees torn multi-key state.Eviction vs TTL: memory pressure and
maxmemory-policycan evict sid keys early; align TTL with your security model (see URL sessions, query tokens, and email links (security)).Affinity is still useful: a shared store fixes F5 to another replica for URL-bound data, but WebSocket lifetimes and CPU caches may still behave better with optional stickiness — see Deployment.
OIDC BFF: URL-session continuity does not replace BFF login state storage; multi-replica BFF still needs externalized state or affinity — see Security architecture.
URL sessions, query tokens, and email links (security) — query tokens, invites, logging, and debug mode.
Roadmap Phase 2 follow-on in Roadmap.
Architecture note in
PLAN.md(“Browser refresh and session continuity”).