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
{{ message }}
Repository navigation
Re-check a WebSocket's identity when its access token expires #430
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.
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.server WebSocket
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 is tokenData.accessToken.
JWT claims decoder, already used for the connected-app check: decodeJwtClaims, apps/hocuspocus.server/src/lib/jwtClaims.ts:8.
Per-connection hook precedent (connected and onDisconnect, state on context): apps/hocuspocus.server/src/extensions/document-occupancy.extension.ts:194 and :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/provider 3.4.4 then marks itself unauthenticated, keeps the socket open, and never reconnects. Its code is fixed at 1000. The Private seal in apps/hocuspocus.server/src/lib/accessRealtime.ts:85-95 relies on that, because the webapp redirects on its own.
Verified tokens are cached for 60 s without an exp check: 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-381 and :384-397.
Fix plan
In onAuthenticate, read exp from decodeJwtClaims(tokenData.accessToken) for a verified user, and add it to the returned context as tokenExp (seconds). Today the context is { user, slug, documentId, deviceType } (:591).
Add src/extensions/token-expiry.extension.ts.
In connected, when context.tokenExp is set, start a timer for tokenExp * 1000 - Date.now().
When it fires, close the underlying socket with connection.webSocket.close(4408, …). Do not call connection.close(); it leaves the tab open but unsynced (see Where).
4408 is in the webapp's self-healing set. The provider reconnects, its token callback fetches a fresh session, and onSynced clears the grace timer.
Keep the timer on context, and clear it in onDisconnect.
Register it in apps/hocuspocus.server/src/config/hocuspocus.config.ts, beside DocumentViewsExtension (:380). Unlike that one, it needs no service-role gate.
No change to the reconnect path. onAuthenticate already refuses a token that auth.getUser rejects. 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.
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 exp leaves no timer behind.
Verify
Local stack: set jwt_expiry = 120 in packages/supabase/config.toml for 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.
changed the title [-][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[/+]on Oct 6, 2026
Summary
onAuthenticateverifies 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:
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.
apps/hocuspocus.serverWebSocketWhere
apps/hocuspocus.server/src/hocuspocus.server.ts:464-592(onAuthenticate). The verified token istokenData.accessToken.decodeJwtClaims,apps/hocuspocus.server/src/lib/jwtClaims.ts:8.connectedandonDisconnect, state oncontext):apps/hocuspocus.server/src/extensions/document-occupancy.extension.ts:194and:238.token: async () => …getSession()):apps/webapp/src/hooks/useYdocAndProvider.ts:110-127.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.expcheck:TOKEN_CACHE_TTL_MS,apps/hocuspocus.server/src/lib/auth.ts:54.jwt_expiry = 3600(packages/supabase/config.toml:156).apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts:290-294,:310,:366-381and:384-397.Fix plan
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).src/extensions/token-expiry.extension.ts.connected, whencontext.tokenExpis set, start a timer fortokenExp * 1000 - Date.now().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.context, and clear it inonDisconnect.apps/hocuspocus.server/src/config/hocuspocus.config.ts, besideDocumentViewsExtension(:380). Unlike that one, it needs no service-role gate.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.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
HocuspocusProviderconstruction in the webapp (CLAUDE.md§Settled, Collab session helpers).Out of scope
Acceptance criteria
exp, and the tab reconnects with no error banner and no lost edits.exp.expleaves no timer behind.Verify
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.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
Generated by Claude Code