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 noStep 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
| Engine | Format | Signed with | Verified by | Lifetime |
|---|---|---|---|---|
yjs, automerge | HS256 JWT over { userId, docName, user?, iat, exp } | the app's own key (see below) | the same Adonis process, on the handshake | 5 minutes |
partykit, partyserver | the same JWT | partykit.jwtSecret | the edge worker | 5 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:
tokenSecretpassed toCollaborationManager(how a test or a non-Adonis embedder supplies one);tokenSecretinconfig/collaboration.ts;- 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:
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 })
}
}<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
| Symptom | Cause |
|---|---|
401 from /token | no user on the context — your auth middleware did not run, or resolveUser needs configuring |
403 from /token | authorize said canRead: false, or no pattern matched and it fell through to deny |
400 from /token | the doc query param is missing |
Client loops connecting → disconnected | the 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.
REST routes
The ten built-in endpoints — the full request and response reference, how they are auto-registered, the three levels of control over them, and why every document name travels as a query or body field.
Binary state
The two machine-to-machine endpoints that carry a document's raw bytes — who calls them, why the write path is secret-authenticated rather than session-authenticated, and what to use them for.