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: InMemorySessionStore supports TTL; cap size with max_entries.

  • Logging: do not log raw tokens at INFO. The gateway structured log field query redacts fluxlit_sid and your configured url_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 get fails 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 hydrate never sees torn multi-key state.

  • Eviction vs TTL: memory pressure and maxmemory-policy can 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.