---
title: Authorization
description: Decide who may open a document with authorize, bind client ids to users, and grant read-only access.
icon: shield-check
---

The room enforces identity, but it does not know your users. You tell it who is connecting in `authorize`, a function `routeDocumentSocket` calls before it opens a socket. This page covers what `authorize` returns, how identity reaches the room, and how the room ties every edit to a user.

## `authorize`

```ts
type AuthorizeDocumentSocket = (
  request: Request,
  documentId: string
) =>
  | DocumentIdentity
  | ExpiredCredential
  | null
  | Promise<DocumentIdentity | ExpiredCredential | null>;

type DocumentIdentity = {
  userId: string;
  replica?: number | null;
  readOnly?: boolean;
};

type ExpiredCredential = { expired: true };
```

`authorize` receives the client's original upgrade request (with its query string and cookies) and the document id you passed to `routeDocumentSocket`. Return `null` to refuse. The room is never reached: `routeDocumentSocket` accepts the upgrade and closes it at once with code `4403` and reason `document access denied`. A browser reports an HTTP 403 at the upgrade as a bare `1006`, the same as a network failure; the `4403` close lets the provider tell a denial from being offline. The provider treats it as final: it emits `refused` once and stops dialing (see [refusals](/docs/collaboration/websocket#refusals)).

Return `{ expired: true }` when the credential was valid but has expired. The dial is accepted and closed with `4401` and reason `expired`, and the room is never reached. The provider does not stop: it emits `expired`, then redials after its backoff, reading `params` again. Put a fresh token in `params` when `expired` fires and the next dial gets in.

| Field | Type | Description |
| --- | --- | --- |
| `userId` | `string` | The verified user: any string of 1 to 256 characters with no lone surrogate, reaching the room as is (spaces included). The room binds every client id this socket writes under to this user. |
| `replica` | `number \| null` | The client's CRDT client id (`document.clientID`), usually read with `requestedReplica(request)`. When omitted, the socket's first presence message binds it. |
| `readOnly` | `boolean` | Refuse every write on this socket. Reads and presence still work. |

The document id is the room id the client dials: any string of 1 to 256 characters except `.`, `..` and one with a lone surrogate (a URL collapses those segments, and cannot encode a lone surrogate, so the provider refuses them), which the provider percent-encodes into one path segment. Decode it with `decodeURIComponent` before you pass it, and close a segment that does not decode with `4400`, as the [quick start](/docs/server/quick-start) does: no provider sends one.

`routeDocumentSocket` answers without calling the room when the request is not a WebSocket upgrade (426), when the document id is empty, `.`, `..`, longer than 256 characters or holds a lone surrogate (closed `4400`), when `authorize` returns `{ expired: true }` (closed `4401`), or when it returns `null` or an invalid identity (closed `4403`).

| Close | Sent when | The provider |
| --- | --- | --- |
| `4400` `invalid document id` | The document id is empty, `.`, `..`, longer than 256 characters or holds a lone surrogate; `authorize` is not called. | Final: emits `refused` and stops dialing. |
| `4401` `expired` | `authorize` returned `{ expired: true }`. | Emits `expired` and redials after its backoff, with `params` read again. |
| `4403` `document access denied` | `authorize` returned `null` or an invalid identity: a `userId` that is empty or not a string, over 256 characters or with a lone surrogate, or a `replica` that is not a client id. | Final: emits `refused` and stops dialing. |
| `4409` `replica bound to another user` | The room: the dial's replica belongs to another user (see [replicas](#replicas)). | Final: emits `refused` and stops dialing. |

Your Worker may turn a dial away itself, before `routeDocumentSocket`: a document that does not exist, an origin you do not serve. Answer the upgrade with `closedSocket(code, reason)` from `edytor/cloudflare` rather than an HTTP error, which the browser reports as `1006` and the provider redials forever. It accepts the upgrade and closes it at once with `code`: use a `4xxx` code other than `4401` for a final refusal (`4404` for a document that does not exist, say), or `1011` for a fault the provider should retry.

```ts
if (!(await documentExists(documentId))) return closedSocket(4404, 'unknown document');
```

## Example: a signed token

Browsers cannot set headers on a WebSocket, so send a token as a query parameter (or rely on a cookie). This example verifies a JWT with [`jose`](https://github.com/panva/jose) and looks up the user's access to the document in your own data:

```ts src/worker.ts
import { jwtVerify } from 'jose';
import { DocumentRoom, closedSocket, requestedReplica, routeDocumentSocket } from 'edytor/cloudflare';

export { DocumentRoom };

type Env = {
  ROOMS: DurableObjectNamespace<DocumentRoom>;
  JWT_SECRET: string;
};

// Your permission model: 'edit', 'view', or null.
declare function accessTo(env: Env, userId: string, documentId: string): Promise<'edit' | 'view' | null>;

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const match = /^\/rooms\/([^/]+)$/.exec(new URL(request.url).pathname);
    if (!match) return new Response('not found', { status: 404 });
    let documentId: string; // the provider percent-encodes the room id
    try {
      documentId = decodeURIComponent(match[1]);
    } catch {
      return closedSocket(4400, 'invalid document id');
    }

    return routeDocumentSocket(request, env.ROOMS, documentId, async (request, documentId) => {
      const token = new URL(request.url).searchParams.get('token');
      if (!token) return null;
      try {
        const { payload } = await jwtVerify(token, new TextEncoder().encode(env.JWT_SECRET));
        if (typeof payload.sub !== 'string') return null;
        const access = await accessTo(env, payload.sub, documentId);
        if (!access) return null;
        return {
          userId: payload.sub,
          replica: requestedReplica(request),
          readOnly: access === 'view'
        };
      } catch (error) {
        // An expired token is retried (4401) once `params` holds a fresh one; a forged one is refused (4403).
        return (error as { code?: string }).code === 'ERR_JWT_EXPIRED' ? { expired: true } : null;
      }
    });
  }
} satisfies ExportedHandler<Env>;
```

The client sends the token; the provider adds its replica id (`?replica=<clientID>`) at every dial:

```svelte
<Edytor server="wss://rooms.example.com/rooms" room={documentId} params={{ token }} actor={{ id: userId }} />
```

`params` is read at every reconnect; pass a new `token` to refresh an expiring one (see [WebSocket options](/docs/collaboration/websocket#options)). An expired token closes the dial with `4401`: the provider emits `expired` (the view's `onSyncExpired`) and dials again with the `params` it then reads, so refresh `token` there or before it expires. A denied dial (`4403`) stops the provider: once the permission is fixed, reload the page, or call `connect()` on a [`WebsocketProvider`](/docs/collaboration/websocket#provider-events-and-state) you created, to dial again. With a session cookie instead, read `request.headers.get('Cookie')` in `authorize` and look the session up; the cookie is sent with the upgrade when the Worker is on the same site.

## How identity reaches the room

After `authorize` succeeds, `routeDocumentSocket` builds a new request for the room (`rooms.getByName(documentId)`, a `DocumentRoom` or any object with `attachDocument`) that carries only the verified identity:

| Header | Value |
| --- | --- |
| `X-Edytor-User` | `userId`, percent-encoded (`encodeURIComponent`) |
| `X-Edytor-Replica` | `replica`, when known |
| `X-Edytor-Access` | `read` or `write` |

Every header the client sent is dropped, cookies and tokens included, and so are forged `X-Edytor-*` headers. The room trusts these three headers without checking anything else, and refuses a request without them (401).

:::danger
Reach the room only through `routeDocumentSocket`. If your Worker forwards requests to the Durable Object any other way, a client could send its own `X-Edytor-*` headers and write as anyone. The names are exported as `IDENTITY_HEADERS` if you need to reference them. With [`attachDocument`](/docs/server/extending) in your own Durable Object, the same holds for `document.fetch`: hand it only requests that came through `routeDocumentSocket`.
:::

## Replicas

Every browser tab has a CRDT client id (`document.clientID`, the *replica*), and every piece of content records the client id that wrote it. The room makes sure nobody writes under a client id that is not theirs:

- Each client id is registered to one user. The registration is stored in the room beside the document, and [`onSave`](/docs/server/extending#save) receives it as `replicas`: store it with `update` and return both from `onLoad`, or a restored room cannot tell who owns which id.
- The socket's own replica (from `authorize`, or bound by its first presence entry) is registered when the socket opens, for read-only sockets too, so nobody can take a viewer's id before it is granted edit. A dial whose replica another user owns is accepted, then closed with `4409` (`replica bound to another user`); the provider treats it as final.
- A user may write under every id registered to them. A reloaded page gets a new client id, and the edits its earlier id made offline are still delivered on reconnect.
- Content under a client id another user owns, or under an unregistered id that already has content, is stripped from the update (logged as a `replica` refusal, with the id). So are the update's deletes of the entries the stripped content replaces, whether or not the room still holds them (a changed attribute keeps its stored value, or arrives intact with its author, instead of being emptied). The rest of the update is applied, including its deletes of that id's items, since anyone who may write may delete: the socket stays connected and its own edits are stored. A delete of an item the room does not hold yet is stored too and waits for that item: it applies when the item arrives.
- Content under an id the room holds nothing of, and no user owns, is *relayed* content: another replica's edits, which this client received and passes on (edits restored from a local copy, or edits a room restored from an older snapshot lost). It is stored, logged as `relayed`, and the id stays unowned: only a socket whose own replica is that id claims it (logged as `orphan`). Delivering an id never makes it yours.

`requestedReplica(request, param = 'replica')` reads the replica from the query string and returns `null` when it is missing or not a valid client id. The shipped provider sends it at every dial. Without a replica from `authorize`, the socket's first presence entry binds it (and registers it, as a dial would); until then, the room cannot tell the socket's own id from one it relays, so an unregistered id without content that the socket writes under is registered to its user.

Registrations of ids that hold no content and belong to no open socket are dropped when the room [compacts](/docs/server/room#storage). After a restore from a bare update (no `replicas`), each id that holds content is unowned until a signed-in writer dials with it.

### Restoring from an older snapshot

If the room's storage is lost and `onLoad` returns a snapshot older than the last edits, clients still hold edits the room lacks, including other users' edits they received. Their handshake delivers them:

- Edits under an id another user owns, or under an id the room already holds part of, are stripped. The author's own client delivers them when it reconnects. The relaying client's own edits are stored meanwhile, its deletions of the author's text included. A deletion of text the room lost is stored and waits for it (up to 1,024 waiting ranges per room, see [storage](/docs/server/room#storage)): when the author's client returns with that text, it arrives deleted, for the room, the other clients and the author. A deletion past that limit is dropped, and the relaying client stays unsaved until a later connection gets it stored. An edit of its own that builds on stripped content waits in the room's memory until the author's edit arrives, with its replacement of any attribute value it rewrites. That memory does not survive the room hibernating or being evicted; the edit reaches the room again at that client's next connection (or its next `resyncInterval` handshake). Any edit of a block whose last change was stripped builds on it, since every edit rewrites the block's last-changed-by attribute: that client stays unsaved until the author returns and its own edit is in the room.
- Edits under an id the room holds nothing of are stored as relayed content, and the id stays claimable by its author's dial.

Every author keeps their client ids, and nobody is disconnected for relaying. The relaying client's [`saved`](/docs/collaboration/websocket#saved-state) turns `true` once the room has stored its user's edits (its waiting deletions included, even when other edits wait on its own; never one the room dropped), after a reload too: it tracks what that user's client ids wrote (the document binds each id to its actor), not the stripped edits of others it received earlier.

Because client ids belong to users, a browser profile's local copy and cross-tab channel should belong to one user. See [one user per browser profile](/docs/collaboration/persistence#one-user-per-browser-profile).

## Read-only sockets

When `authorize` returns `readOnly: true`:

- the client catches up with the document and receives live edits;
- it shares presence, so others see its caret;
- the room never asks it for its state. It sends the socket a read-only notice when it joins (the provider's `readOnly` turns `true`, with no event), and drops every edit it sends with a `permission-denied` reply (`read-only`). The socket stays open, and the provider emits `'permission-denied'` for each dropped edit, which for `read-only` is not a failure. The provider counts nothing as unsaved, so its [`saved`](/docs/collaboration/websocket#saved-state) is `true` once it caught up.

Render read-only users with a read-only view. A `readonly` view does not attach its `sync` prop, so attach the sync to the document yourself:

```ts
const document = createDocument({ actor: { id: userId } });
document.attachSync(createWebsocketSync({ server: serverUrl, room: documentId, params }));
// <Edytor {document} readonly />
```

## What edytor does not do

Edytor ships no user accounts, sessions, sharing model or permission rules. `authorize` is where your application decides who may open which document and whether they may edit it; the room enforces that decision and nothing more.
