Agora
REST routes

Token flow

How a browser gets a credential a WebSocket can carry — the five steps, what the signed token contains, where its key comes from, what happens when one expires, and how the reconnect loop turns expiry into revocation.

new WebSocket(url) takes a URL and nothing else. No headers, no fetch options, no way to attach a bearer token or a custom auth scheme. Everything about how this library authenticates a real-time connection follows from that one line of browser API.

The five steps

1. GET /collaboration/token?doc=researches/42/writing
2.   ↳ routes.resolveUser(ctx)      → the authenticated user, or 401
3.   ↳ authorize(ctx, docName)      → canRead === false ? 403
4.   ↳ 200 { token, wsUrl, engine }
5. new WebSocket(`${wsUrl}?doc=…&token=${token}`)
     ↳ authorize(ctx, docName) again → refused if it says no

Step 2 is where your normal auth works: a session cookie on a same-origin request, or the headers getHeaders supplies from a separate frontend. Step 3 is the permission seam, and step 5 crosses it a second time — the socket does not trust the token's contents, it re-asks.

The client does all of this for you. useCollabDoc fetches the token, reads the engine from the response, picks the matching transport and opens the socket; you never see steps 1 through 5.

The response

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1XzEiLCJkb2NOYW1lIjoicmVzZWFyY2hlcy80Mi93cml0aW5nIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDAzMDB9.OaJ0…",
  "wsUrl": "/collaboration",
  "engine": "yjs",
  "expiresAt": 1700000300000
}

engine is what makes the client engine-agnostic — it is the server telling the browser which protocol this document speaks. wsUrl is a relative path for self-hosted engines (the client resolves it against its base URL) and an absolute wss:// origin for edge engines.

One format, two keys

EngineFormatSigned withVerified byLifetime
yjs, automergeHS256 JWT over { userId, docName, user?, iat, exp }the app's own key (see below)the same Adonis process, on the handshake5 minutes
partykit, partyserverthe same JWTpartykit.jwtSecretthe edge worker5 minutes

Three things are checked before authorize is consulted, on every engine: the signature, the expiry, and the document — a token issued for researches/42/writing does not open researches/43/writing. The client picks the document name it connects to, so without that last check "may read one document" would quietly mean "may read every document".

The self-hosted token is also not a grant: the process that issued it re-runs authorize when the socket arrives, so a revocation between issue and connect still bites. The signature answers a different question — who is this? — and it is the question the old format could not answer.

The edge JWT is a grant as well as an identity. A Durable Object has no database, cannot call authorize, and has nothing to check the claim against — so the signature carries the whole decision. See Edge engines.

Upgrading from 0.12 or earlier

The self-hosted token used to be an unsigned base64 of { userId } with no expiry. Anyone could write one: no session, no cookie, no login — send someone else's id and the handshake authorized you as them, on any document. Tokens in that format are no longer accepted. Clients reconnect and fetch a new one, so the visible cost of upgrading is one reconnect.

Where the signing key comes from

Nothing to configure in a normal app: the library derives its key from the app's appKey (domain-separated, so the same bytes are not doing double duty as a cookie signer). It resolves, in order:

  1. tokenSecret passed to CollaborationManager (how a test or a non-Adonis embedder supplies one);
  2. tokenSecret in config/collaboration.ts;
  3. the app's appKey.

With none of the three the library refuses to issue and refuses to accept tokens, and says so — there is no fallback to an unsigned format, because a fallback is how the old hole would come back. The issuer and the driver read the same source through the manager, so they cannot end up on different keys.

Rotating the key invalidates every outstanding token; clients fetch a new one on their next reconnect.

Expiry is the feature

Tokens expiring is the normal path, not an error. The client's session watches the transport, and on a disconnect it tears the transport down, fetches a fresh token, and rebuilds — with exponential backoff doubling per failed attempt, capped at 15 seconds, and reset the moment a connection succeeds.

That loop is quietly doing something important: because every reconnect re-runs authorize, access revoked while someone is connected takes effect at the next reconnect without anyone reloading a page. Revocation is not a separate mechanism — it is the same check, running again.

Note the "at the next reconnect" precisely. authorize is evaluated once per connection and never re-evaluated for a socket that stays open, so a user whose access you revoke keeps syncing until their transport drops. If a revocation must bite immediately, close the connection from the app side; do not wait for the rule to notice.

Which also means authorize runs often

Every handshake, every token issue, every reconnect. Keep it to one indexed query. If your rule is genuinely expensive, cache it in your own layer — the library deliberately does not cache it for you, because a cached permission is a permission that outlives its revocation.

If it throws — the database it queries is down — that is reported as a CollabAuthorizationError, not as a denial, and surfaced through the error stream. The distinction matters: a client told "forbidden" should stop, and a client told "unavailable" may back off and retry, which is the difference between a blip and a reconnect storm.

Issuing it before the browser asks

Steps 1 to 4 are a round-trip to a server that, moments earlier, rendered the page — and already knew every part of the answer. issueCollaborationToken lets that render hand the token over directly, so the browser opens the socket on mount instead of waiting for an HTTP request first:

app/controllers/writing_controller.ts
import { issueCollaborationToken } from '@adonis-agora/collaboration'

export default class WritingController {
  async show(ctx: HttpContext) {
    const docName = `researches/${ctx.params.id}/writing`
    const collabToken = await issueCollaborationToken({ ctx, docName })

    return ctx.inertia.render('writing', { docName, collabToken })
  }
}
inertia/pages/writing.tsx
<CollaborationProvider initialTokens={{ [docName]: collabToken }}>
  <Editor docName={docName} />
</CollaborationProvider>

useCollabDoc({ docName, token }) takes one directly too. Prefer initialTokens when more than one hook touches the same document (useAwareness, useCollabEditor): whichever mounts first is the one that creates the session, and the provider's token is available to all of them.

It is the same token. issueCollaborationToken runs authorize, resolves the document's engine through the manager and builds wsUrl exactly as the route does — a credential minted from a controller is indistinguishable from one the endpoint would have issued for the same caller. A shortcut that skipped the permission check because it was reached from a controller would not be a shortcut, it would be a hole.

It throws instead of returning a response, because in a page controller there is no response to return: CollabUnauthorizedError with no caller, CollabForbiddenError when authorize denies canRead. Letting both bubble fails the render, which is the honest outcome — an editor rendered for someone who may not read the document is worse than an error page.

Pass user instead of ctx when the controller already resolved the caller:

await issueCollaborationToken({ user: { id: String(user.id), name: user.fullName }, docName })

Used once, on purpose

A pre-issued token covers the first connection and nothing else. Reconnects go back to GET /collaboration/token, because a token minted at render time is precisely the one that has expired by the time a socket drops an hour later — and because re-fetching is what re-runs authorize, which is what makes revocation take effect at all.

A pre-issued token that is malformed or already expired is discarded by the client, which then fetches one as it always did. The response carries expiresAt for the formats that have an expiry, so a page prop that sat in a sleeping tab can be recognised as dead without opening a socket to find out. A bad prop can cost the first paint; it can never leave the document unable to connect.

Failure modes

SymptomCause
401 from /tokenno user on the context — your auth middleware did not run, or resolveUser needs configuring
403 from /tokenauthorize said canRead: false, or no pattern matched and it fell through to deny
400 from /tokenthe doc query param is missing
Client loops connectingdisconnectedthe token is issued but the handshake is refused — the two paths resolved differently, or the socket never reached path

See Troubleshooting for how to tell these apart quickly.

Taking it over

issueToken is exported, so a custom controller keeps the exact same semantics — the same two formats, the same authorize enforcement, the same wsUrl construction:

import { issueToken } from '@adonis-agora/collaboration'

const engine = await collaboration.engineFor({ docName })
const issued = await issueToken(
  { engine, path: '/collaboration', partykit: config.partykit },
  { id: String(auth.user!.id), name: auth.user!.fullName },
  docName,
  (ctx, doc) => collaboration.authorize(ctx, doc),
)

Passing the manager's authorize — rather than your own copy of the global callback — is what keeps per-document rules applying. See Custom controllers.

On this page