Auth
Decide who may read and write which documents
Without an authenticate hook the sync server trusts everyone who can
reach it, the same as Vite's own dev socket. That is right for local
development and wrong for anything shared. Passing authenticate puts
every surface (the websocket, uploads, asset serving) behind your policy.
Enforcement is server-side only. The client sends credentials and reports what the server decided; it never restricts itself. Turning "this user cannot write" into a read-only editor is your code.
Add an authenticate hook
authenticate runs once per websocket connection (again on every reconnect,
so refreshed tokens apply) and once per HTTP media request. It maps the
request to a scope, or returns null to reject.
How credentials reach it depends on your setup:
Cookies travel with every request and websocket upgrade, so the server reads them directly and the client needs no changes:
import type { SyncAuthenticate } from "@fumadocs-editor/core/node";
// your session store; the sync server only cares about the resulting scope
declare function verifySession(
cookie: string | undefined,
): Promise<{ name: string; isEditor: boolean } | null>;
export const authenticate: SyncAuthenticate = async ({ request }) => {
const session = await verifySession(request.headers.cookie);
if (!session) return null;
return {
user: { name: session.name },
write: session.isEditor,
};
};
Scopes
Prop
Type
A document is identified by its root-relative posix path, so read and
write predicates are per-document permissions. Two rules:
- Predicates see the normalized path.
team-a/../outside.mdxcannot slip past ateam-a/rule. - Predicates run on the message hot path and must be synchronous. Resolve
async policy inside
authenticateand close over the result, as the token example does. Policy changes apply on the next reconnect.
What each surface checks
| Surface | Rule |
|---|---|
list | filtered to readable paths |
read, opening a collab doc | requires read |
| watch / change broadcasts | withheld for unreadable paths |
write (compare-and-swap) | requires write |
| collab edits (Y updates) | refused without write; read-only peers still receive |
| media upload | requires write on assets/<name> |
| asset serving | requires read on the asset path |
| presence identity | a scope user overrides the name the client claims |
A denied path is a per-request error; the connection stays open. Rejected
credentials close the connection: the client reports denied rather than
offline and stops retrying. A connection that never authenticates is
closed after helloTimeoutMs.
Media uploads
The HTTP endpoints cannot see the websocket hello, so the same payload
travels in the x-fde-auth header (exported as AUTH_HEADER),
JSON-encoded:
import { AUTH_HEADER, UPLOAD_ENDPOINT } from "@fumadocs-editor/core/sync";
import type { MediaProvider } from "@fumadocs-editor/ui";
declare function getAccessToken(): Promise<string>;
export const media: MediaProvider = {
async upload(file) {
const res = await fetch(UPLOAD_ENDPOINT, {
method: "POST",
body: file,
headers: {
"x-filename": encodeURIComponent(file.name),
// the same payload the transport sends on hello, JSON-encoded
[AUTH_HEADER]: JSON.stringify(await getAccessToken()),
},
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
const { src } = (await res.json()) as { src: string };
return src;
},
};
Cookie setups skip the header; the cookie is already on the request.
What the client learns
The reply to a read (and the collab handshake) carries the scope data your UI needs:
Prop
Type
sync.onOpen hands it to you. See Read-only for wiring
writable into the editor.
Last updated on