Repository navigation
Re-check a WebSocket's identity when its access token expires #430
Description
Summary
onAuthenticate verifies the access token once, when the WebSocket connects. Nothing checks it again. So after a ban, an account delete, or a sign-out on another device, an open tab keeps its identity until it closes. A banned owner keeps owner rights on their own Read-only or Private documents. The Redis access channel closes sockets only when a document turns Private or is deleted.
Publishing a close event from the admin API, as first proposed, would cover almost none of these cases:
- An operator can ban or delete a user in the Supabase dashboard, and that publishes nothing.
- The only ban in this code is the ghost-account delete. After Re-check ghost status before an admin delete, and lock the last-admin guard #412, it refuses any user who still counts as active. A ghost has no confirmed sign-in, or is an anonymous account, and an anonymous account owns no documents.
Closing each socket when its token expires covers every case, in one place. The webapp then reconnects with a fresh token, and Supabase refuses to refresh a banned or deleted user's session.
- Severity: Low. Public documents are editable without signing in, so the gain is small.
- Area:
apps/hocuspocus.serverWebSocket - Source: security review of 2026-10-06, finding L10
Where
- Token check, once per connection:
apps/hocuspocus.server/src/hocuspocus.server.ts:464-592(onAuthenticate). The verified token istokenData.accessToken. - JWT claims decoder, already used for the connected-app check:
decodeJwtClaims,apps/hocuspocus.server/src/lib/jwtClaims.ts:8. - Per-connection hook precedent (
connectedandonDisconnect, state oncontext):apps/hocuspocus.server/src/extensions/document-occupancy.extension.ts:194and:238. - The webapp fetches a fresh session on every connect (
token: async () => …getSession()):apps/webapp/src/hooks/useYdocAndProvider.ts:110-127. - Close codes the webapp treats as self-healing:
apps/webapp/src/hooks/collabSession.ts:8. connection.close()does not close the socket. It sends a document Close message, and@hocuspocus/provider3.4.4 then marks itself unauthenticated, keeps the socket open, and never reconnects. Its code is fixed at1000. The Private seal inapps/hocuspocus.server/src/lib/accessRealtime.ts:85-95relies on that, because the webapp redirects on its own.- Verified tokens are cached for 60 s without an
expcheck:TOKEN_CACHE_TTL_MS,apps/hocuspocus.server/src/lib/auth.ts:54. - Local access-token lifetime is
jwt_expiry = 3600(packages/supabase/config.toml:156). - Ban and delete calls that publish nothing:
apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts:290-294,:310,:366-381and:384-397.
Fix plan
- In
onAuthenticate, readexpfromdecodeJwtClaims(tokenData.accessToken)for a verified user, and add it to the returned context astokenExp(seconds). Today the context is{ user, slug, documentId, deviceType }(:591). - Add
src/extensions/token-expiry.extension.ts.- In
connected, whencontext.tokenExpis set, start a timer fortokenExp * 1000 - Date.now(). - When it fires, close the underlying socket with
connection.webSocket.close(4408, …). Do not callconnection.close(); it leaves the tab open but unsynced (see Where). 4408is in the webapp's self-healing set. The provider reconnects, itstokencallback fetches a fresh session, andonSyncedclears the grace timer.- Keep the timer on
context, and clear it inonDisconnect. - Register it in
apps/hocuspocus.server/src/config/hocuspocus.config.ts, besideDocumentViewsExtension(:380). Unlike that one, it needs no service-role gate.
- In
- No change to the reconnect path.
onAuthenticatealready refuses a token thatauth.getUserrejects. On the local stack, confirm that a banned user's session cannot refresh. The reconnect must then be refused or carry no user. - Get maintainer sign-off on the cost: every signed-in tab reconnects about once per token lifetime (1 hour by default). The reconnect sends only the missing updates.
Layer: the timer lives in a Hocuspocus extension, like occupancy and views. Do not add a server-wide interval, and do not call the Supabase admin API from the WebSocket process. Do not change HocuspocusProvider construction in the webapp (CLAUDE.md §Settled, Collab session helpers).
Out of scope
- A close event published from the admin API. It would close a ghost's sockets in seconds instead of at token expiry, but per the Summary it reaches almost no one. Add it only if a real in-app ban path appears.
- Clearing the profile of a soft-deleted user. See Clear the public profile when an account is soft-deleted #427.
Acceptance criteria
- A signed-in socket closes at its token
exp, and the tab reconnects with no error banner and no lost edits. - After a ban or delete, the user's sockets lose that identity within one token lifetime plus 60 s. The reconnect is refused, or it connects with no user.
- A socket with no access token has no timer and never closes this way. An anonymous account gets a timer from its own token
exp. - Disconnecting before
expleaves no timer behind.
Verify
- Local stack: set
jwt_expiry = 120inpackages/supabase/config.tomlfor the test only, and restart Supabase. Open a document signed in, wait two minutes, and watch the socket reconnect in the browser network panel with no banner. Revert the setting. - Then ban the user with
auth.admin.updateUserById(<id>, { ban_duration: '1h' })from a service-role script, and check that the next reconnect is refused or carries no user. bun run --filter @docs.plus/hocuspocus typecheck.
Related
- Re-check ghost status before an admin delete, and lock the last-admin guard #412 (ghost delete safety; it limits the ghost path to inactive accounts)
- Clear the public profile when an account is soft-deleted #427 (soft-deleted account keeps its profile)
Generated by Claude Code
Activity
[-][Security] An open WebSocket keeps its identity after a ban, delete or sign-out[/-][+]Re-check a WebSocket's identity when its access token expires[/+]fix(hocuspocus): refuse bad document ids and close sockets at token exp
docs(hocuspocus): sync server docs with the 9 October fixes
docs: update the API and self-hosting guides for the 9 Oct backend fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Fixed and live.
369961dabbbdaae66c(production run 37976785981, green).exp, and the reconnect re-checks the identity.