Session And Device Management
A Sessions section at /user/settings lists the account's active sessions — which browser, when it was last active, which one you are reading from — with a confirm-guarded revoke per row and a sign-out-everywhere-else action. It closes the gap OAuth-only sign-in leaves open: revoking a provider grant stops future sign-ins, but the session rows are ours and keep working until they expire.
What a row shows
A row renders what the session row already stores, and nothing more:
- The browser and device, from
getDeviceLabel, which parses the storeduserAgentwithbowseron the server: a browser and its major version (Chrome 141), then the most specific device the string still knows. A model wins wherever bowser has one — every Apple device, and the table of older handsets it still recognises by name — and everything else lands on the OS name, because Chrome froze the model out of the modern Android agent string. No OS version is shown, because none can be trusted: Windows 11 still sendsNT 10.0, and Safari and Chrome both freeze macOS at10_15_7. When bowser recognises nothing in the string, the row readsUnknown devicerather than echoing the agent back raw. - Last active, as a
NuxtTimelike every rendered date (date and time display). The value is the session'supdatedAt, which better-auth refreshes on its own session-update age rather than on every request — so this is "recently" rather than "to the second", and reads as such. - This device, marked on the current row, because that is the one whose button signs the reader out rather than removing someone else.
Neither the address nor the raw user agent leaves the server. SessionSummary is the shape the endpoint returns: a deviceLabel the parse has already produced, and no address field at all. An address does not help the holder recognise a session, and a shared screenshot would carry it; the agent string is unreadable and states more than the reader asked for. Parsing server-side is also what keeps bowser out of the client bundle. Place would be worth showing, and stays out for a settled reason: Railway hands the app no geo header, so it would take a database — and every free one is a multi-megabyte file committed here and refreshed by hand, or a credential and a third party the address gets sent to. That is a dependency rather than a computation. Until the trade changes, the row shows a device and a time, which is enough to recognise.
Revoking
sequenceDiagram
actor Owner
participant Card as Sessions card
participant R as session router
participant PG as sessions
participant BA as better-auth
participant PS as pushSubscriptions
participant WPS as Web PubSub
Owner->>Card: Revoke (confirm dialog)
Card->>R: deleteSession(id)
R->>PG: own unexpired rows — resolves the token, and is the ownership check
R->>BA: revokeSession({ token })
BA->>PG: row deleted
PG->>PS: sessionId cascades
R->>WPS: closeUserConnections(deviceId) — best-effort
Note over Card: current row instead lands on the login route
A session token never reaches the client. The client names a session by id; the router reads the caller's own unexpired rows, resolves the token there, and revokes with that. Scoping the read to the caller is the ownership check — an id that is not in it is a NOT_FOUND rather than someone else's session being signed out.
The reads are ours, the writes are better-auth's, and the split is not stylistic. better-auth's listSessions endpoint sits behind its freshness middleware, which rejects any session older than freshAge — a day by default — so a reader who signed in yesterday would get a SESSION_NOT_FRESH where the card should be. Its revoke endpoints ask only for a valid session, so those are called directly, which keeps the session table better-auth's to mutate: a session cache or secondary storage added later invalidates with the revoke instead of behind its back.
A server read of the session forwards the extended cookie or leaves the extension alone. better-auth extends a session read a day after its last extension and answers with the extended cookie, but a read made through auth.api.getSession drops that answer unless it asks for the headers. Taken there, the row's expiry moved on while the browser's cookie kept the one it was set with, so a reader who came back every day was still signed out a week after signing in (better-auth#2115). readSession is the server's one read: a request whose response can carry a cookie — a tRPC call over HTTP, a page before it renders — forwards it, and one that cannot, a socket's or a cached asset's, reads with disableRefresh. The page's read runs in a server middleware ahead of the render, because the render's own session read is an internal fetch whose cookies never reach the browser.
Expired rows are filtered out of the listing, the same way better-auth's own listing filters them — nobody is signed in with a session that has run out, and showing it invites a revoke that does nothing.
A push subscription belongs to the session that created it, not to the browser that once signed in. pushSubscriptions.sessionId references sessions and cascades, so a revoke takes that device's pushes with it — and so do a plain sign-out and an expiry, with no cleanup path of their own. A subscription outliving its session still resolves and still delivers (push notifications), which is the hole this closes: keyed by account alone, a device someone had signed out of kept receiving that account's pushes.
Losing the row costs nothing, which is what makes the cascade cheap rather than destructive: usePushSubscription resubscribes on mount, so the next authenticated load writes it again under the session actually in use. That is also why the migration clears the existing rows — they predate the column, so no value would satisfy the constraint, and each browser rewrites its own on its next visit.
The constraint is worth the friction it caused. Writing a subscription now requires its session to exist as a row, which turned out to be false in the test harness rather than in the app: auth.api.getSession was mocked to fabricate session identity that nothing had ever stored. Since its one caller awaits it, the mock now writes the row it fabricates, which is what the real sign-in does — so the suite exercises the same invariant production does instead of one nothing enforced.
One genuine schema fix travels with this: sessions.userId cascades on the user now, like every other row a user owns. It never did, so deleting a user who still held a session violated the constraint — a defect the suite was hiding, because no test had a session row until this feature gave sessions a reader.
Live connections are closed explicitly, because a Web PubSub client access url outlives the session that minted it. Web PubSub identifies a connection by device — userId⟂sessionId, see generateWebPubSubClientAccessUrl — so closeUserConnections addresses exactly one session's connections. It is best-effort in full, the service client included: the revoke has already landed by then, so a hub that cannot be reached costs a connection living until it drops on its own, rather than a revoke reported as failed.
Revoking your own row is sign-out: the wording says so before the click, and the card lands on the login route rather than refreshing a listing the page can no longer read. The auth cookie is left in place and inert — it names a session row that no longer exists, so the next request resolves no session.
Confirmation
Both destructive actions confirm through the shared UiConfirmDialog (destructive confirmation) at the plain-confirm tier — no type-the-name guard, because a revoked session is re-created by signing in again. The per-row dialog is a singleton targeted by revokingId in store/user/sessionDialog; the sign-out-everywhere-else dialog mounts once beside the list, so it stays a button and its dialog in one component.
Not included
No admin-facing counterpart. An operator terminating another user's sessions is a moderation capability, and moderation acts on room membership rather than on the account surface (moderation) — if it is ever wanted it is its own design, not a permission bolted onto a self-service card.
Procedures
| Procedure | Auth | Purpose |
|---|---|---|
session.readSessions | authed | own sessions as SessionSummary[], the current one marked |
session.deleteSession | authed | revoke one session by id, then close its connections |
session.deleteOtherSessions | authed | revoke every session but the current one, then close theirs |
Key files
| File | Role |
|---|---|
apps/web/server/trpc/routers/session.ts | the three procedures |
apps/web/server/services/auth/readSession.ts | the server's session read, forwarding its cookie |
apps/web/server/middleware/session.ts | a page's session read ahead of its render |
apps/web/server/models/session/SessionSummary.ts | what a row is allowed to say — no address |
apps/web/server/services/auth/closeDeviceConnections.ts | best-effort per-device Web PubSub close |
apps/web/app/components/User/SessionsCard/ | the card, its row, and the two confirm dialogs |
apps/web/server/services/auth/getDeviceLabel.ts | the stored user agent → a readable device label |
apps/web/app/store/user/sessionDialog.ts | the singleton revoke target |
packages/db-schema/src/schema/notification/pushSubscriptionsInNotification.ts | sessionId, cascading on the session row |
packages/db-schema/src/schema/auth/sessionsInAuth.ts | the session rows, now cascading on the user |