Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api-routes/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ The API transformer provides a way to:

This is useful for creating a backend API that can be used for purposes beyond your Reflex app, or for integrating Reflex with existing backend services.

Routes accessing Reflex state inherit the requesting browser's session. See
[browser sessions](/docs/api-routes/sessions/) for ownership checks, trusted
server-side access, and deployment configuration.

## Using API Transformer

You can set the `api_transformer` parameter when initializing your Reflex app:
Expand Down
140 changes: 140 additions & 0 deletions docs/api-routes/sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
title: Browser Sessions
---

# Browser Sessions

Reflex can bind each tab's state to a signed browser session. The browser keeps
the session credential in an HTTP-only cookie; the `client_token` identifies a
tab and is not sufficient to access its state when enforcement is enabled.
This establishes ownership of state, not a user identity or login. Application
authentication and authorization still belong in your app or AuthPlugin.

## Rollout

Set `REFLEX_SESSION_TOKEN_MODE` in the backend environment:

| Mode | Behavior |
| --- | --- |
| `warn` (default) | Issues sessions and warns about unauthorized state access, while allowing it for compatibility. |
| `enforce` | Requires session ownership for event contexts and uploads. A socket presenting an unbound client token receives a new token and fresh state. |
| `off` | Disables ownership enforcement. Fresh tabs receive a client token; existing legacy tokens remain usable. |

Use `enforce` after updating your frontend, integration clients, and
reflex-enterprise. Legacy tokens are not bound to a session on first use: doing
so would let anyone who learned an old token claim its state. Existing state
therefore resets when an old tab first migrates to a bound token. Cached old
frontends remain usable in `warn`; they cannot persist a new session cookie and
will lose state on reconnect under `enforce`.

## Startup and refresh

The WebSocket connects first. If it has no valid session cookie, the backend
issues a session and a bound tab token in-band. Hydration and `on_load` can run
while the frontend exchanges that credential at `POST /_reflex/session` in the
background. Uploads wait for this exchange to finish and remain cancellable
while waiting. Transient exchange failures retry with backoff while ordinary
WebSocket events continue; waiting HTTP requests resume when the cookie is ready.
Disconnecting cancels the pending exchange. The credential is never
stored in localStorage or sessionStorage.

The endpoint also supports a JSON POST with no credential to create a session
for a non-browser client. It returns `token_type`, `expires_in`, `refresh_in`, and
`client_token`, and sets the cookie. Pass an existing `Reflex-Client-Token`
header to retain an owned tab token. The endpoint honors `backend_path`.
Reflex-enterprise's bearer issuer at `/_reflex/auth/token` is unchanged.

Fresh browser tabs coordinate initial cookie creation with Web Locks. Browsers
without Web Locks cannot guarantee that simultaneous first connections share
one session; if a competing cookie wins, the affected tab reconnects with the
server's replacement token. Waiting HTTP requests remain queued until that
replacement connection is accepted. Storage keys are scoped to the backend URL.

`session_token_ttl` in `rx.Config` defaults to seven days.
`session_token_refresh_interval` defaults to half that lifetime. HTTP responses
refresh an existing session after that age. Browser timers renew idle sessions;
returning to a visible tab also requests renewal over its authenticated socket,
so a suspended timer does not require a new session while that socket survives.
A socket retains its validated session for its
connected lifetime. Cookie expiry is checked on subsequent HTTP requests and
reconnections. Refresh retains the session ID and existing tab bindings.

## Origins, cookies, and embedding

Cookies use `Secure; HttpOnly; SameSite=None; Partitioned; Path=/`, no Domain,
and an app-specific `__Host-` name. Set `api_url` to the public backend URL,
Comment on lines +64 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This describes the __Host- name as universal, but non-loopback HTTP development uses the bare cookie name because _cookie_name() disables the prefix when the cookie is insecure. Qualify this sentence to apply only to secure cookies so deployment and cookie-debugging guidance matches the implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/api-routes/sessions.md, line 64:

<comment>This describes the `__Host-` name as universal, but non-loopback HTTP development uses the bare cookie name because `_cookie_name()` disables the prefix when the cookie is insecure. Qualify this sentence to apply only to secure cookies so deployment and cookie-debugging guidance matches the implementation.</comment>

<file context>
@@ -0,0 +1,140 @@
+
+## Origins, cookies, and embedding
+
+Cookies use `Secure; HttpOnly; SameSite=None; Partitioned; Path=/`, no Domain,
+and an app-specific `__Host-` name. Set `api_url` to the public backend URL,
+including when TLS terminates at a reverse proxy. Cookies are not scoped by
</file context>
Suggested change
Cookies use `Secure; HttpOnly; SameSite=None; Partitioned; Path=/`, no Domain,
and an app-specific `__Host-` name. Set `api_url` to the public backend URL,
Secure cookies use `Secure; HttpOnly; SameSite=None; Partitioned; Path=/`, no Domain,
and an app-specific `__Host-` name; non-loopback HTTP development uses an insecure
cookie without the `__Host-` prefix. Set `api_url` to the public backend URL,

including when TLS terminates at a reverse proxy. Cookies are not scoped by
port, so applications sharing a host must have distinct `app_name` values.
The signed audience also binds each credential to `app_name`, so one app cannot
accept another app's session even when they share a signing key.

Installing an in-band credential requires a trusted Origin. Reflex trusts
`deploy_url`, `api_url`, explicit `cors_allowed_origins`, and the development
loopback frontend origins. `cors_allowed_origins=["*"]` does **not** authorize
credential exchange. Configure `deploy_url` for the frontend and list any
EmbedPlugin host origins explicitly. This prevents another origin from
installing a session credential it already knows. A valid existing cookie
always takes precedence over an exchanged credential.

Partitioned cookies allow cross-site iframes and separate frontend/backend
sites in browsers that support them. The session belongs to the top-level
site's partition. Browsers that block third-party cookies without supporting
partitioned cookies cannot persist sessions in those contexts. Plain HTTP on
a non-loopback development host uses a warning and an insecure `SameSite=Lax`
cookie; use HTTPS for embedding and production.

Cookie headers remain readable on the server through `self.router.headers`.
They are omitted from frontend router data, and the session credential itself
is stripped before request headers reach app code. Use `rx.Cookie` for cookies
that intentionally need to be readable in browser JavaScript.

## Signing keys

Set `REFLEX_SESSION_SECRET` to a strong random secret of at least 32 bytes in
your deployment's secret manager or environment. Never put it in `rxconfig.py`.
If you load it from a local `.env`, add that file to `.gitignore`.

Without the variable, Reflex creates a 256-bit secret in
`.web/backend/session_secret`, created with owner-only permissions on POSIX.
On Windows, access follows the working directory's inherited ACLs. Workers on one host
share it, and it survives hot reload, `reflex init`, and upgrades. Exported
bundles exclude it. Production logs a warning when the generated key is used.
All replicas and replacement deployments must receive the same environment
secret to preserve sessions.

For rotation, supply a comma-separated list with the new key first and the
previous key second. New cookies use the first key; all listed keys verify.
Keep the previous key for at least the maximum lifetime of cookies it signed.
Refreshing a cookie preserves tab bindings, even after its old signing key is
retired. There is no per-session revocation mechanism in this release.

## Server-side state access

`EventContext.session_token` contains a validated `SessionToken` with a
non-secret `id`, `issued_at`, `expires_at`, and `authorizes(client_token)`.
It does not imply that the client is logged in.

`app.modify_state`, event enqueueing, and chained events inherit the ambient
session. HTTP and custom WebSocket routes receive the requesting session, and
framework WebSocket events receive the socket's session. Explicit `session_token=None` means anonymous
access. A handler cannot modify another session's state under `enforce`.

Lifespan work starts with `SessionToken.SYSTEM`. For intentional trusted
server-side fan-out, pass it explicitly:

```python
from reflex_base.session import SessionToken

async with app.modify_state(
rx.BaseStateToken(client_token, MyState),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 State update example fails

The server-side example passes positional arguments to BaseStateToken, but its inherited constructor requires keyword arguments. Copying it raises TypeError before app.modify_state runs. Use rx.BaseStateToken(ident=client_token, cls=MyState).

Suggested change
rx.BaseStateToken(client_token, MyState),
rx.BaseStateToken(ident=client_token, cls=MyState),

session_token=SessionToken.SYSTEM,
) as state:
state.message = "Server maintenance starts soon"
```

Do not use `SYSTEM` to bypass checks on client-supplied tokens in HTTP routes.
The low-level state-manager APIs remain trusted internals; framework network
entry points perform their checks before loading state. Custom endpoints that
call state-manager APIs directly must provide their own authorization.
Apply rate limiting to `/_reflex/session` through your existing reverse proxy
or `api_transformer` middleware; do not trust arbitrary forwarded-IP headers.
2 changes: 1 addition & 1 deletion news/+browser-session-transport.feature.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Add browser sessions using HttpOnly cookies and a `POST /_reflex/session` endpoint that issues a client token bound to the requesting session. Sessions scope HTTP and custom WebSocket state access; authenticated sockets can renew their cookie credentials without losing client state.
Add browser sessions using HttpOnly cookies and a `POST /_reflex/session` endpoint that issues a client token bound to the requesting session. Sessions scope HTTP and custom WebSocket state access; authenticated sockets can renew their cookie credentials without losing client state. See the [browser sessions guide](https://reflex.dev/docs/api-routes/sessions/) for configuration and migration.
1 change: 1 addition & 0 deletions news/+session-harness.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use the same hostname for the development AppHarness frontend and backend so browser cookies remain same-site.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This fragment reads as an instruction to configure a hostname, but AppHarness now sets the backend URL to localhost itself. Describe the change instead, e.g. that AppHarness now points the dev frontend at localhost, so browser cookies stay same-site in tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At news/+session-harness.bugfix.md, line 1:

<comment>This fragment reads as an instruction to configure a hostname, but AppHarness now sets the backend URL to `localhost` itself. Describe the change instead, e.g. that AppHarness now points the dev frontend at `localhost`, so browser cookies stay same-site in tests.</comment>

<file context>
@@ -0,0 +1 @@
+Use the same hostname for the development AppHarness frontend and backend so browser cookies remain same-site.
</file context>
Suggested change
Use the same hostname for the development AppHarness frontend and backend so browser cookies remain same-site.
AppHarness now points the development frontend at `localhost` for the backend API, so browser cookies remain same-site during tests.

Original file line number Diff line number Diff line change
@@ -1 +1 @@
Establish browser sessions over the WebSocket while hydration begins, then persist them through HTTP-only cookies with automatic refresh. Browser storage is isolated for each backend application. Idle tabs renew their session cookies automatically; transient cookie-exchange failures retry without blocking WebSocket events, and queued uploads remain cancellable.
Establish browser sessions over the WebSocket while hydration begins, then persist them through HTTP-only cookies with automatic refresh. Browser storage is isolated for each backend application. Idle tabs renew their session cookies automatically; transient cookie-exchange failures retry without blocking WebSocket events, and queued uploads remain cancellable. See the [browser sessions guide](https://reflex.dev/docs/api-routes/sessions/) for configuration and migration.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
File uploads now carry the browser session cookie and reject client tokens belonging to another session when `REFLEX_SESSION_TOKEN_MODE=enforce` is enabled.
File uploads now carry the browser session cookie and reject client tokens belonging to another session when `REFLEX_SESSION_TOKEN_MODE=enforce` is enabled. See the [browser session guide](https://reflex.dev/docs/api-routes/sessions/) for configuration and migration.
14 changes: 9 additions & 5 deletions reflex/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,8 @@ def _start_frontend(self):
with chdir(self.app_path):
config = get_config()
print("Polling for servers...") # for pytest diagnosis #noqa: T201
config.api_url = "http://{}:{}".format(
*self._poll_for_servers(timeout=30).getsockname(),
)
# Match the dev frontend's hostname so browser cookies are same-site.
config.api_url = f"http://localhost:{self._poll_for_servers(timeout=30).getsockname()[1]}"
print("Building frontend...") # for pytest diagnosis #noqa: T201
reflex.utils.build.setup_frontend(self.app_path)

Expand Down Expand Up @@ -923,7 +922,12 @@ def _wait_frontend(self):
) + config.prepend_frontend_path("/")
config.deploy_url = self.frontend_url

def _start_backend(self):
def _start_backend(self, port: int = 0):
"""Start a production backend with its worker settings on the requested port.

Args:
port: Backend port, or zero to choose an available port.
"""
if self.app_asgi is None:
msg = "App was not initialized."
raise RuntimeError(msg)
Expand All @@ -933,7 +937,7 @@ def _start_backend(self):
uvicorn.Config(
app=self.app_asgi,
host="127.0.0.1",
port=0,
port=port,
workers=reflex.utils.processes.get_num_workers(),
),
)
Expand Down
8 changes: 4 additions & 4 deletions tests/benchmarks/support/idle_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import socketio

from tests.benchmarks.support.socket_client import _connect
from tests.benchmarks.support.socket_client import _connect, create_session


def main() -> int:
Expand All @@ -23,13 +23,13 @@ def main() -> int:
Returns:
Process exit code.
"""
url, token_prefix, count, payload_json = sys.argv[1:5]
url, _token_prefix, count, payload_json = sys.argv[1:5]
payload = json.loads(payload_json)
clients: list[socketio.SimpleClient] = []
try:
for index in range(int(count)):
for _ in range(int(count)):
client = socketio.SimpleClient(logger=False)
_connect(client, url, f"{token_prefix}-{index}", "/_event", timeout=10)
_connect(client, url, create_session(url), "/_event", timeout=10)
client.emit("event", payload)
response = client.receive(timeout=10)
while response and response[0] != "event":
Expand Down
72 changes: 59 additions & 13 deletions tests/benchmarks/support/socket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import functools
import json
import time
import urllib.request
from collections.abc import Callable, Mapping
from typing import Any
from urllib.parse import quote
Expand All @@ -20,6 +21,39 @@
_PRIME_DRAIN_TIMEOUT = 0.5


@dataclasses.dataclass(frozen=True)
class ClientSession:
"""Server-issued credentials for one benchmark client and its reconnects."""

token: str
cookie: str = dataclasses.field(repr=False)


def create_session(url: str, timeout: float = 10) -> ClientSession:
"""Acquire session credentials before starting a measured connection.

Args:
url: Backend URL, including any backend path prefix.
timeout: Maximum HTTP request wait in seconds.

Returns:
The bound client token and its session cookie.
"""
request = urllib.request.Request(
f"{url.rstrip('/')}/_reflex/session",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.load(response)
cookie = "; ".join(
value.partition(";")[0]
for value in response.headers.get_all("Set-Cookie", [])
)
return ClientSession(token=payload["client_token"], cookie=cookie)


@dataclasses.dataclass(frozen=True)
class ClientLoadResult:
"""Latency observations and failures for one load client."""
Expand Down Expand Up @@ -56,7 +90,7 @@ def _payload_size(response: Any) -> int:
def _connect(
client: socketio.SimpleClient,
url: str,
token: str,
token: str | ClientSession,
namespace: str,
timeout: float,
) -> None:
Expand All @@ -65,16 +99,20 @@ def _connect(
Args:
client: Blocking Socket.IO client.
url: Backend Socket.IO URL.
token: Reflex client token.
token: Session credentials, or a legacy token for a baseline server.
namespace: Reflex Socket.IO namespace.
timeout: Maximum connection wait.
"""
separator = "&" if "?" in url else "?"
headers = {"Origin": url}
if isinstance(token, ClientSession):
headers["Cookie"] = token.cookie
token = token.token
client.connect(
f"{url}{separator}token={quote(token)}",
transports=["websocket"],
socketio_path="_event",
headers={"Origin": url},
headers=headers,
namespace=namespace,
wait_timeout=max(1, int(timeout)),
)
Expand Down Expand Up @@ -120,7 +158,7 @@ def _prime(

async def run_socket_client(
url: str,
token: str,
token: str | ClientSession,
payload: Mapping[str, Any],
events: int,
*,
Expand All @@ -134,7 +172,7 @@ async def run_socket_client(

Args:
url: Backend Socket.IO URL.
token: Reflex client token.
token: Session credentials, or a legacy token for a baseline server.
payload: Event payload emitted for each operation.
events: Number of operations.
event_name: Socket event name used for requests.
Expand Down Expand Up @@ -165,7 +203,7 @@ async def run_socket_client(

def _run_socket_client_sync(
url: str,
token: str,
token: str | ClientSession,
payload: Mapping[str, Any],
events: int,
event_name: str,
Expand All @@ -177,7 +215,7 @@ def _run_socket_client_sync(

Args:
url: Backend Socket.IO URL.
token: Reflex client token.
token: Session credentials, or a legacy token for a baseline server.
payload: Event payload emitted for each operation.
events: Number of operations.
event_name: Socket event name used for requests.
Expand Down Expand Up @@ -215,13 +253,16 @@ def _run_socket_client_sync(
client.disconnect()

return ClientLoadResult(
token, tuple(latencies), tuple(errors), tuple(payload_sizes)
token.token if isinstance(token, ClientSession) else token,
tuple(latencies),
tuple(errors),
tuple(payload_sizes),
)


async def run_reconnect_client(
url: str,
token: str,
token: str | ClientSession,
payload: Mapping[str, Any],
*,
event_name: str = "event",
Expand All @@ -234,7 +275,7 @@ async def run_reconnect_client(

Args:
url: Backend Socket.IO URL.
token: Reflex client token.
token: Session credentials, or a legacy token for a baseline server.
payload: Event payload emitted after connecting.
event_name: Socket event name used for the request.
response_name: Socket event name carrying state updates.
Expand Down Expand Up @@ -263,7 +304,7 @@ async def run_reconnect_client(

def _run_reconnect_client_sync(
url: str,
token: str,
token: str | ClientSession,
payload: Mapping[str, Any],
event_name: str,
response_name: str,
Expand All @@ -274,7 +315,7 @@ def _run_reconnect_client_sync(

Args:
url: Backend Socket.IO URL.
token: Reflex client token.
token: Session credentials, or a legacy token for a baseline server.
payload: Event payload emitted after connecting.
event_name: Socket event name used for the request.
response_name: Socket event name carrying state updates.
Expand Down Expand Up @@ -307,7 +348,12 @@ def _run_reconnect_client_sync(
if client.connected:
client.disconnect()

return ReconnectResult(token, connect_ms, first_response_ms, tuple(errors))
return ReconnectResult(
token.token if isinstance(token, ClientSession) else token,
connect_ms,
first_response_ms,
tuple(errors),
)


async def run_clients(
Expand Down
Loading
Loading