Authorization
The permission seam — why a WebSocket needs a token endpoint, how the same authorize callback guards both doors, what fail-closed means in practice, and which of the three permissions the library actually enforces.
A browser cannot attach an Authorization header to a WebSocket handshake. new WebSocket(url)
takes a URL and nothing else. That single limitation shapes the entire security model of every
real-time library, this one included, and it is worth understanding before you write your first
authorize.
Two doors, one lock
Since the socket cannot carry credentials, the client asks for a short-lived credential over HTTP first, where cookies and headers work normally, and then puts that credential in the socket URL:
1. GET /collaboration/token?doc=researches/42/writing
↳ your auth middleware resolves the user (session cookie, bearer token, …)
↳ authorize(ctx, docName) ──▶ canRead === false ? 403, nothing issued
↳ 200 { token, wsUrl, engine }
2. new WebSocket(`${wsUrl}?doc=…&token=${token}`)
↳ authorize(ctx, docName) ──▶ canRead === false ? handshake refusedauthorize runs on both. Not a cached copy of its answer from step 1 — the callback itself,
again, with the same document name. A token that was valid when it was minted does not grant
access if the user lost it in between, and a hand-crafted socket URL that skips the token endpoint
still meets the same check.
Why not just check once?
Because the two paths are reachable independently. The token endpoint sits behind your HTTP middleware; the WebSocket upgrade does not. Checking only at issue time would make the socket trust a string, and checking only at the socket would let the token endpoint hand credentials to anyone. The seam is only a seam if both sides of it are closed.
Once per connection, not continuously
authorize runs at the handshake and on each REST request — and nowhere else. Nothing
re-evaluates it for a socket that is already open. Revoking a user's access does not disconnect
them; they keep syncing that document until they close the tab, and are refused on their next
connection.
If a revocation has to bite immediately, the app has to make it bite: close the connection from your side, or force the document to reload. Do not plan around the rule noticing on its own.
Fail-closed, by construction
authorize resolution has three steps and a floor:
- the matched declared document's own
authorize; - the global
config.authorize; - deny —
{ canRead: false, canWrite: false, canComment: false }.
There is no fourth step where an unmatched document is treated as public. If you declare patterns
for three document types and a client asks for a fourth, it is refused. If you forget to write
authorize entirely, everything is refused. The published config/collaboration.ts even ships
an authorize that throws, so an unconfigured install fails loudly instead of quietly serving
documents to the internet.
The practical consequence: a client stuck on connecting is usually a pattern that doesn't
match, not a broken socket. See Troubleshooting.
The connection context
authorize receives what the library knows about the caller:
interface CollabConnectionContext {
/** The authenticated user's id, as a string. */
userId: string
/** Free-form metadata carried into presence — name, avatar. */
user?: { name?: string; avatarUrl?: string | null }
}That is deliberately thin. It is an identity, not a session — you look up everything else yourself,
which is why authorize is async:
async authorize(ctx, docName) {
const membership = await Membership.query()
.where('user_id', ctx.userId)
.andWhere('workspace_id', workspaceOf(docName))
.first()
if (!membership) return { canRead: false, canWrite: false, canComment: false }
return {
canRead: true,
canWrite: membership.role !== 'viewer',
canComment: true,
}
}Where does userId come from? On the REST path, from routes.resolveUser — which defaults to
reading ctx.auth.user and throws a 401 when there isn't one, so an unprotected deployment
fails closed rather than issuing anonymous tokens. On the socket path, from the token the client
presents, which the endpoint minted from that same resolved user.
routes: {
// Only needed when your user isn't at ctx.auth.user
resolveUser: (ctx) => ({ id: String(ctx.currentTenantUser.id), name: ctx.currentTenantUser.name }),
}Because authorize is async and runs on every handshake, keep it cheap. One indexed query is
fine; three joins and a permission-matrix rebuild will be felt on every reconnect. Cache in your
own layer if it gets expensive — the library deliberately does not cache the answer for you.
Throwing is not denying
If your rule throws — the query it makes hits a database that is down — that is not the same
answer as canRead: false, and the library does not report it as one. The failure is pushed to
the error stream and raised as a
CollabAuthorizationError (a 4503 close code on the Automerge socket) rather than a
CollabForbiddenError (4003). A client told "forbidden" should stop; a client told "unavailable"
should back off and retry. Collapsing the two is what turns a blip on your side into a reconnect
storm.
What the three permissions actually do
All three are enforced by the library, on both the socket and the REST surface:
| Permission | On the WebSocket | On the REST routes |
|---|---|---|
canRead | the handshake is refused | /token, /comments (GET), /versions (GET) and /state (GET) answer 403 |
canWrite | the connection is made read-only — inbound updates are dropped, outbound sync continues | /versions (POST) and /versions/restore answer 403 |
canComment | — | /comments POST answers 403; PATCH and DELETE need it and authorship |
canComment is permission to add an annotation, not to silence someone else's. Resolving or
deleting an existing comment is author-or-elevated: the author may always mutate their own, and
anyone else needs canWrite on the document. The rule is exported as canMutateComment so a
controller of your own can apply exactly the same one.
canWrite: false binds the connection, not the UI. A client that is merely told it may not
write can simply not ask, so the rule is applied where the update arrives: Hocuspocus marks the
connection read-only, and the Automerge driver drops that client's inbound messages. The client
still receives everyone else's edits — read-only means read-only, not disconnected.
Read them on the client too, for a UI that tells the truth
Enforcement is the server's job; explaining it is the UI's. Drive editable from canWrite and
hide the comment composer when canComment is false, so a user is not offered an action that will
be refused. That is presentation, not security — the server no longer depends on it.
Token shape and lifetime
One wire format for every engine — you never construct it by hand, but knowing what is in it explains the security properties:
| Engine | Format | Signed with | Verified by | Lifetime |
|---|---|---|---|---|
yjs, automerge | HS256 JWT over { userId, docName, user?, iat, exp } | your app's key (tokenSecret, else appKey) | your own Adonis process, on the handshake | 5 minutes |
partykit, partyserver | the same JWT | partykit.jwtSecret | the edge worker | 5 minutes |
Signature, expiry and document are all checked before authorize is consulted: a token issued
for one document does not open another, because the client is the one choosing which document to
connect to.
On the self-hosted path the token proves who is connecting and authorize still decides what
they may do — it re-runs on the handshake, so a revocation between issue and connect bites. On the
edge the JWT is also the grant: the worker has no database and cannot re-check anything. See
Edge engines.
Until 0.13 the self-hosted token was an unsigned base64 blob with no expiry, so anyone could mint one for anyone — see Token flow for what changed and what upgrading costs (one reconnect).
Tokens expiring is normal, not exceptional: the client's session rebuilds the transport with a
fresh token on every reconnect, with exponential backoff capped at 15 seconds. Your authorize
runs again each time — which is what makes revocation take effect without anyone reloading a page.
Documents
How a document name becomes an engine, an authorization rule and a row — pattern matching with typed params, engineFor, the resolution order, and why names never travel as path segments.
Versions
Named checkpoints over a live document — how a version is created, listed, restored into the running session and diffed, and why Yjs snapshots and Automerge's change graph answer the same API differently.