You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
LocalTokenManager.link_token_to_sid (introduced in #4607, Reflex 0.8.5) treats any reconnection where the same client_token arrives
with a different sid as a "duplicate tab" and generates a new token via emit("new_token", ...).
Because state managers (StateManagerDisk, StateManagerRedis) index state by client_token, regenerating it silently discards the
user's session — authenticated state, variables, everything. The user sees the UI reset and (if auth is in place) is effectively
logged out.
The duplicate-tab heuristic fires in at least two non-duplicate-tab scenarios:
WebSocket reconnect after a transient close. The browser opens a new socket because the previous one dropped, but the server
hasn't processed the old socket's disconnect yet. In production behind Caddy we measured the window between the new connect and the
old disconnect: ~800 ms. During that window the check token_to_socket[token].sid != sid triggers "duplicate".
This is not a new observation: @masenf raised exactly this concern in a review comment on #4607 on Jan 21 2025:
"Revisiting this PR, is there ever a case where we wouldn't get on_disconnect to unlink the sid/token, resulting in a
refresh/reconnect leading to loss of token and state for a client?"
@benedikt-bartscher suggested opening a formal issue on Nov 1 2025. This is that issue.
To Reproduce
Any Reflex 0.8.5+ app with authenticated state and a reverse proxy in front (tested with Caddy 2, but any proxy that can close idle TCP
connections or any transient network blip reproduces):
Open the app, log in (OIDC or any auth that sets state on connect).
Leave the tab idle ~60-120 s (time depends on the proxy's idle/keepalive settings).
Interact again — navigate or trigger an event.
The browser opens a new WebSocket (expected — transient close).
Reflex emits new_token, the client's client_token rotates, the auth state is gone.
Reproducible 100% of the time on our end. @hr-alebel reports the same for the backend-restart variant, also 100% reproducible.
Expected behavior
A transient WebSocket reconnect by the same browser should preserve client_token and the associated state. The "duplicate tab"
heuristic should either:
Only fire when the old sid is confirmed alive (not merely still in the map), or
Reuse the token for the new sid and let the stale sid mapping be cleaned up on its eventual on_disconnect.
Actual behavior — evidence from production logs
Sequence captured with socketio.server and engineio.server loggers at INFO and a custom wrapper around EventNamespace.on_connect / on_disconnect. Portal behind Caddy 2, Reflex 0.8.28.post1, StateManagerDisk:
12:00:12 INFO engineio.server | iA82ql0ePeBzV8pyAAAE: Sending packet PING
12:00:12 INFO engineio.server | iA82ql0ePeBzV8pyAAAE: Received packet PONG
12:00:37 INFO engineio.server | iA82ql0ePeBzV8pyAAAE: Sending packet PING
12:00:56 INFO engineio.server | 4rg_LN_9Z6veURYDAAAG: Upgrade to websocket successful
12:00:56 INFO portal_web_gic.ws | WS connect sid=PicQCyE7PIbmpLuvAAAH
12:00:56 INFO socketio.server | emitting event "new_token" to PicQCyE7PIbmpLuvAAAH [/_event]
12:00:56.800 WARNING portal_web_gic.ws | WS disconnect sid=GfxpxXQDQQHhiihfAAAB reason=transport close
The key detail: new_token is emitted to the new sid before the old sid's transport close is processed (~800 ms gap). pingTimeout is 120 s so this isn't ping-timeout-related; the server still considers the old sid alive when the new one registers.
Environment
Reflex: 0.8.28.post1
Python: 3.13.12
State manager: StateManagerDisk
Deployment: Docker (backend + frontend + internal nginx), behind Caddy 2 as edge reverse proxy, Keycloak OIDC for auth
Browser: Chrome 147 on Windows 10
Same behavior reported by @hr-alebel on any Reflex version ≥ 0.8.5 under backend restarts with Redis-persisted state.
Workaround (production-tested)
We applied a monkey-patch that replaces LocalTokenManager.link_token_to_sid with a version that reuses the token instead of generating a new one on duplicate detection, and removes the old sid→token mapping so the eventual stale disconnect doesn't wipe the shared
token. The accepted trade-off is that intentional duplicate tabs share state — acceptable for our internal corporate portal with
per-user auth.
Patch (≈20 lines, applied via LocalTokenManager.link_token_to_sid = patched_fn before rx.App()):
asyncdeflink_token_to_sid_reuse(self, token: str, sid: str) ->None:
existing=self.token_to_socket.get(token)
ifexistingisnotNoneandexisting.sid!=sid:
# Drop the stale sid→token mapping so the old sid's eventual# on_disconnect doesn't wipe the token the new sid is using.self.sid_to_token.pop(existing.sid, None)
self.token_to_socket[token] =SocketRecord(
instance_id=self.instance_id, sid=sid,
)
self.sid_to_token[sid] =tokenreturnNone# never emit new_token
Since the patch modifies the base class, it covers RedisTokenManager too.
Guidance on whether the preferred upstream fix is (a) the reuse-token approach above, (b) a liveness check on the stale sid before
declaring duplicate, or (c) something else. Happy to open a PR if there's alignment on the direction.
Description
LocalTokenManager.link_token_to_sid(introduced in #4607, Reflex 0.8.5) treats any reconnection where the sameclient_tokenarriveswith a different
sidas a "duplicate tab" and generates a new token viaemit("new_token", ...).Because state managers (
StateManagerDisk,StateManagerRedis) index state byclient_token, regenerating it silently discards theuser's session — authenticated state, variables, everything. The user sees the UI reset and (if auth is in place) is effectively
logged out.
The duplicate-tab heuristic fires in at least two non-duplicate-tab scenarios:
hasn't processed the old socket's
disconnectyet. In production behind Caddy we measured the window between the newconnectand theold
disconnect: ~800 ms. During that window the checktoken_to_socket[token].sid != sidtriggers "duplicate".This is not a new observation: @masenf raised exactly this concern in a review comment on #4607 on Jan 21 2025:
@benedikt-bartscher suggested opening a formal issue on Nov 1 2025. This is that issue.
To Reproduce
Any Reflex 0.8.5+ app with authenticated state and a reverse proxy in front (tested with Caddy 2, but any proxy that can close idle TCP
connections or any transient network blip reproduces):
new_token, the client'sclient_tokenrotates, the auth state is gone.Reproducible 100% of the time on our end. @hr-alebel reports the same for the backend-restart variant, also 100% reproducible.
Expected behavior
A transient WebSocket reconnect by the same browser should preserve
client_tokenand the associated state. The "duplicate tab"heuristic should either:
on_disconnect.Actual behavior — evidence from production logs
Sequence captured with
socketio.serverandengineio.serverloggers at INFO and a custom wrapper aroundEventNamespace.on_connect/on_disconnect. Portal behind Caddy 2, Reflex 0.8.28.post1,StateManagerDisk:The key detail:
new_tokenis emitted to the new sid before the old sid'stransport closeis processed (~800 ms gap).pingTimeoutis 120 s so this isn't ping-timeout-related; the server still considers the old sid alive when the new one registers.Environment
StateManagerDiskSame behavior reported by @hr-alebel on any Reflex version ≥ 0.8.5 under backend restarts with Redis-persisted state.
Workaround (production-tested)
We applied a monkey-patch that replaces
LocalTokenManager.link_token_to_sidwith a version that reuses the token instead of generating a new one on duplicate detection, and removes the old sid→token mapping so the eventual staledisconnectdoesn't wipe the sharedtoken. The accepted trade-off is that intentional duplicate tabs share state — acceptable for our internal corporate portal with
per-user auth.
Patch (≈20 lines, applied via
LocalTokenManager.link_token_to_sid = patched_fnbeforerx.App()):Since the patch modifies the base class, it covers
RedisTokenManagertoo.Related
Would appreciate
Guidance on whether the preferred upstream fix is (a) the reuse-token approach above, (b) a liveness check on the stale sid before
declaring duplicate, or (c) something else. Happy to open a PR if there's alignment on the direction.