Security architecture¶
Read this before putting real user data behind FluxLit: how the gateway splits API vs UI, where tokens should live, and how to avoid common leaks (XSS, Referer, forward-auth).
FluxLit serves one public origin: the gateway forwards /api to FastAPI and everything else (including WebSockets) to Streamlit. Authentication must account for two execution contexts — API route handlers and server-side Streamlit code — plus whatever runs in the user’s browser.
Step-by-step recipes (JWT, OIDC BFF, Streamlit clients) live in Auth recipes; upgrading an existing app is covered in Migrating to JWT and OIDC.
OIDC BFF: production constraints¶
Process memory only: Login
stateand one-timeauth_codevalues live in in-memory dicts on the BFF config. Use a single API worker process (or a single replica) unless you replace this with a shared store. Multiple Uvicorn workers or horizontally scaled replicas will see broken or flaky logins unless state is externalized.id_tokenvalidation: When you useGenericOIDCClient, the BFF validates the IdPid_tokenwith JWKS (signature,iss,aud,exp) before minting the first-party access token. For a custom :class:~fluxlit.auth.oidc.OIDCProvider, parse-onlysubextraction is disabled by default; setOIDCBFFConfig.allow_unverified_id_token_for_custom_oidc=Trueonly when your provider already verified the token or for controlled tests (see Security architecture).
Threat model (high level)¶
Risk |
Mitigation direction |
|---|---|
XSS in Streamlit UI |
Do not store long-lived refresh tokens or IdP client secrets in |
Token leakage via URL |
OAuth callbacks that put secrets in query strings can leak via Referer, logs, and shared links. FluxLit’s BFF pattern uses a short-lived one-time |
CSRF |
If you add cookie-based sessions, use SameSite and anti-CSRF patterns; document that Streamlit’s model is not a generic SPA. Prefer bearer tokens from server-side exchange for API calls. |
Spoofed forward-auth headers |
Use |
Clock skew |
JWT |
Credential leakage via header forwarding |
|
Gateway → Streamlit: HTTP vs WebSocket headers¶
HTTP proxy hop¶
Baseline: For each request, upstream headers are seeded from the client scope after :func:
fluxlit.gateway.header_filter.filter_request_headers(drops hop-by-hop headers, clientHost, and clientX-Forwarded-*). Most other client header lines are copied — including typicalCookieandAuthorizationvalues — before FluxLit applies syntheticHost, trustedX-Forwarded-*/ prefix lines, andX-Request-ID.Optional allowlist (0.10+):
FLUXLIT_GATEWAY_FORWARD_CLIENT_HEADERS_TO_STREAMLITmerges allowlisted names from the raw browser scope onto that header map. It does not remove headers that were already copied in step 1. An empty list means “skip this merge”; it does not disable cookie or bearer forwarding at the wire. Names likeauthorizationandcookiecannot appear in the allowlist.Script visibility:
st.context.headersand :class:~fluxlit.pages.di.Headerread what Streamlit exposes; that may differ from the full upstream request.
WebSocket proxy¶
The allowlist does not apply. :mod:fluxlit.gateway.websocket_proxy forwards most browser handshake headers to the Streamlit upstream, except a fixed denylist (notably Sec-WebSocket-Extensions is dropped so the gateway’s own compression negotiation does not break the upstream handshake).
Guidance¶
Threats: header smuggling, accidental logging of sensitive values, cache poisoning if names overlap with cache semantics — treat the allowlist like firewall policy even though it only adds lines.
Recommendation: keep allowlists minimal; validate edge trust (
FLUXLIT_FORWARDED_ALLOW_IPS,trust_proxy) per Production TLS and edge headers; useset_page_header_context()when you need explicit, validated injection for typed pages.
Where tokens should live¶
IdP client secrets: FastAPI environment, secret manager, or deps — never in Streamlit subprocess env visible to untrusted code paths.
Access tokens for
ApiClient: Prefer a factory (auth_header_factory) or session state populated only after a server-side exchange; TTL should be short.Internal API base:
FLUXLIT_INTERNAL_API_BASEpoints at your mounted API (e.g.http://127.0.0.1:8000/api). Keep it loopback or private-network in deployment guides. Server-side :class:~fluxlit.client.ApiClientrejects absolute and scheme-relativepatharguments sohttpxcannot be tricked into calling arbitrary hosts.
End-to-end sketch (same origin)¶
This ties together the gateway split: browser hits one host; APIs live under /api; Streamlit uses ApiClient server-side.
from __future__ import annotations
import os
from typing import Annotated, Any
from fastapi import Depends
from fluxlit import FluxLit, bearer_headers_from_session
from fluxlit.client import ApiClient
from fluxlit.config import FluxlitSettings
from fluxlit.auth.jwt import JWTAuthConfig, JWTBearer, StandardClaims, issue_hs256_access_token
settings = FluxlitSettings(
enable_security_headers=True,
public_base_url=os.environ.get("FLUXLIT_PUBLIC_BASE_URL", "http://127.0.0.1:8000"),
)
app = FluxLit(title="Secure FluxLit", settings=settings)
# Or: _bearer = app.make_jwt_bearer() with FLUXLIT_JWT_* set on FluxlitSettings
_bearer = JWTBearer(
JWTAuthConfig(
issuer="https://example.internal",
audience="example-api",
algorithms=["HS256"],
hs256_secret=os.environ["JWT_HS256_SECRET"],
)
)
@app.api.get("/me")
def me(claims: Annotated[StandardClaims, Depends(_bearer)]) -> dict[str, str | None]:
return {"sub": claims.sub}
@app.api.post("/login/dev")
def login_dev() -> dict[str, str]:
"""Replace with real OIDC / corporate IdP before production."""
token = issue_hs256_access_token(
subject="ada",
issuer="https://example.internal",
audience="example-api",
secret=os.environ["JWT_HS256_SECRET"],
ttl_seconds=900,
extra_claims={"scope": "read"},
)
return {"access_token": token}
@app.page("/")
def home(st: Any, client: ApiClient) -> None:
_ = client
if "access_token" not in st.session_state:
st.info("POST /api/login/dev or your IdP flow, then store access_token in session.")
return
hdr = lambda: bearer_headers_from_session(st, session_key="access_token")
with ApiClient(auth_header_factory=hdr) as api:
st.write(api.get("/me").json())
Run with fluxlit dev app:app. The browser calls /api/me with Authorization: Bearer … only from trusted code (here, the Streamlit server via ApiClient), not from arbitrary browser JavaScript on the same page.