Skip to content

Re-check a WebSocket's identity when its access token expires #430

Description

@HMarzban

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:

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

  1. 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).
  2. 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.
  3. 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.
  4. 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 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.
  • bun run --filter @docs.plus/hocuspocus typecheck.

Related


Generated by Claude Code

Activity

  1. added theissue type on Oct 6, 2026
  2. 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
  3. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Fixed and live.

    • Commits: 369961dab
    • Deployed in bbdaae66c (production run 37976785981, green).
    • A signed-in socket closes with 4408 at its token exp, and the reconnect re-checks the identity.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    AuthSecuritySecurity, access control, and data exposurebugSomething isn't working

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions