Auth
Ferry has no users of its own. auth tells it who sent each request, with whatever your app already uses: a session cookie, a token, an auth library.
import { createSyncServer, memoryStore } from 'ferrysync/server';
import { schema } from './schema';
export const syncServer = createSyncServer({
schema,
store: memoryStore(),
// The user's id, or null for a 401.
auth: async (request) => (await getSession(request))?.userId ?? null,
});On the device, the client sends whatever your server reads, on every request:
import { createSync, http } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
backend: http({
url: 'https://api.example.com/sync',
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
onUnauthorized: () => showToast('Please sign in again'),
}),
});auth(request)
| It returns | The server |
|---|---|
| a user id | answers as that user: their private space and the spaces they joined |
null (or '') | answers 401; the client calls onUnauthorized and retries later |
| throws | answers 500, and hands the error to onError |
- It runs once per request, before the body is read, so an anonymous request costs nothing more.
requestis your runtime's ownRequest: read headers, cookies and the URL as anywhere else.- It may be async.
A bearer token
import { createSyncServer, memoryStore } from 'ferrysync/server';
import { schema } from './schema';
export const syncServer = createSyncServer({
schema,
store: memoryStore(),
auth: async (request) => {
const header = request.headers.get('authorization');
if (header === null || !header.startsWith('Bearer ')) {
return null;
}
return verifyToken(header.slice('Bearer '.length)); // your check: the user's id, or null
},
});With an auth library, ask it for the session from the request's headers, as Better Auth's
auth.api.getSession({ headers: request.headers }) does. For tokens from Clerk, Supabase or your
own issuer, verify the bearer token as above.
The user id
The id auth returns is the user, as far as sync goes.
- Make it stable. It names the user's private space (
user:<id>) and their memberships. An email address changes; a user's primary key does not. - It is opaque. Ferry never parses it; any non-empty string works.
A device belongs to one user
Each device has a client id, and the first user who syncs with it owns it. When another user signs
in on the same device without wiping it, the server answers 403 (forbidden). Sign-out should wipe
the device with sync.reset(local()): see Accounts.
Frameworks that parse bodies
fetch reads the body itself. When your framework already parsed it, call handle with the parsed
body, the endpoint (the path's last segment) and the request, for auth. It sets no size limit:
your framework's body parser does (maxBodyBytes applies to fetch only):
import { syncServer } from './sync-server';
export const route = async (request: Request, endpoint: string, body: unknown) => {
const { status, body: answer } = await syncServer.handle({ endpoint, body, request });
return Response.json(answer, { status });
};When the user is known only to your framework (a middleware set it on its own context), pass it along: with Hono, with Express.
Gotchas
- Keep
authcheap. Besides each write, every device with realtime on sends a request about every 25 seconds. Cache sessions, or verify a signed token without a database read. - Allow the header across origins. A web app on another origin sends
authorizationonly if your CORS settings allow it: see Hono's CORS. - Refresh tokens in
headers. The function runs before every request, so a token that expires is replaced on the next one.onUnauthorizedis where the app signs out when it cannot refresh. - A deleted account must get
null. A token verified only by its signature stays valid until it expires, and the server cannot tell a deleted user from a new one: a device still let in writes their data again. Check revocation, or keep a short list of deleted ids next to the signature check: see Deleting users.