Agora
Concepts

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 refused

authorize 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:

  1. the matched declared document's own authorize;
  2. the global config.authorize;
  3. 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:

config/collaboration.ts
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.

config/collaboration.ts
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:

PermissionOn the WebSocketOn the REST routes
canReadthe handshake is refused/token, /comments (GET), /versions (GET) and /state (GET) answer 403
canWritethe 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:

EngineFormatSigned withVerified byLifetime
yjs, automergeHS256 JWT over { userId, docName, user?, iat, exp }your app's key (tokenSecret, else appKey)your own Adonis process, on the handshake5 minutes
partykit, partyserverthe same JWTpartykit.jwtSecretthe edge worker5 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.

On this page