Open your account page in most apps and look at the active sessions list. If it looks anything like ours did, the same laptop appears five times, every entry is named after a browser version, and none of them can be told apart. Revoking one feels like pulling a wire out of an unlabeled fuse box.
Foxl v0.4.7 rebuilds session identity around a simple rule: one installation, one identity. This post covers why the old model produced duplicates, why the fix touched token rotation and the desktop tunnel too, and how the whole thing deploys without logging anyone out.
User-Agent strings are not devices
The relay used to identify a session by its refresh token and label it with the browser's User-Agent string. That breaks in both directions at once. Two different machines running the same Safari version collapse into one label. And the same machine mints a "new device" whenever the UA changes - a browser update, a webview versus a tab, the Electron shell versus the site. Every login stacked another row onto the list.
v0.4.7 gives each Foxl installation and browser profile a stable client_id, minted once on first launch and sent with every login and token refresh. Sessions now group by that identity. Old UA-labeled rows migrate in place: duplicates collapse into one entry per device, and revoking a session signs out exactly that installation - nothing else.
The refresh race that looked like theft
While rebuilding identity we found a sharper bug. Refresh tokens rotate on use: spend one, get a replacement, and reusing a spent token means theft - the relay revokes the whole session family. That is the correct security posture, with one exception we were hitting ourselves.
On desktop, two app surfaces can refresh the same session within milliseconds of each other. Whichever call lost the race presented an already-spent token. To the relay that was indistinguishable from theft, so it revoked everything, and the user got logged out for having two windows open.
The fix makes rotation idempotent inside a five-second concurrency window. Rotation is a single guarded D1 batch: the losing caller's update simply matches zero rows, and it gets handed the winner's replacement token instead of a revocation. Replays outside the window are still treated as theft. Same security boundary, no more self-inflicted logouts.
Fencing the tunnel
The same deploy hardens the desktop tunnel with connection generations. Every WebSocket gets a connection_id, and status writes are fenced by it - a delayed close event from a dead connection can no longer mark its own replacement offline. Reconnects after sleep collapse into a single jittered backoff, and web and mobile clients now learn tunnel status from push instead of a polling loop.
Shipping identity changes without logging anyone out
Auth migrations are where "just deploy it" goes to die. Every active session predates the new schema, so the new code treats client_id as optional context, not a requirement: tokens without one keep working, and each device adopts its identity on its next login or refresh. The schema shipped to production before the worker, in the same release run, so the new code never raced columns that did not exist yet.
The credit meter got the same discipline. Balances used to refresh on a 15-second poll from every open surface; they now update when a turn completes, when the app resumes, or on manual refresh. Combined with push-first tunnel status, steady-state database reads per idle client drop to roughly zero.
v0.4.7 is live across all surfaces. Open your account page - the sessions list should finally look like your actual devices.