Fumadocs Editor

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:

server.ts
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.mdx cannot slip past a team-a/ rule.
  • Predicates run on the message hot path and must be synchronous. Resolve async policy inside authenticate and close over the result, as the token example does. Policy changes apply on the next reconnect.

What each surface checks

SurfaceRule
listfiltered to readable paths
read, opening a collab docrequires read
watch / change broadcastswithheld for unreadable paths
write (compare-and-swap)requires write
collab edits (Y updates)refused without write; read-only peers still receive
media uploadrequires write on assets/<name>
asset servingrequires read on the asset path
presence identitya 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:

media.ts
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

On this page