Ferry
Examples

Your own auth

Ferry has no users of its own. The server asks one question of every request, auth(request), and your code answers it: a user id, or null for a 401. Whatever already signs your users in works.

Server
import type { SyncServerOptions } from 'ferrysync/server';

type Auth = SyncServerOptions<never>['auth']; // (request) => string | null, or a promise of one

The id is yours to choose (your database's user id, a provider's sub), as long as it stays the same for the same person: rows and space memberships are kept under it.

When the app and its backend share a domain, the browser sends the session cookie with every sync request, so auth reads it like any other route does:

sync-server.tsServer
import { createSyncServer, memoryStore } from 'ferrysync/server';

import { schema } from './schema';

export const syncServer = createSyncServer({
  schema,
  store: memoryStore(),
  auth: async (request) => (await getSession(request))?.userId ?? null,
});

A bearer token from any provider

Native apps, and web apps on another domain, send a token instead. Verify it against the provider's public keys, with no call to the provider per request:

sync-server.tsServer
import { createSyncServer, memoryStore } from 'ferrysync/server';
import { createRemoteJWKSet, jwtVerify } from 'jose';

import { schema } from './schema';

const keys = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));

/** The token's subject, when its signature, issuer and audience check out. */
const userOf = async (request: Request) => {
  const token = request.headers.get('authorization')?.replace(/^Bearer /, '');
  if (!token) {
    return null;
  }
  try {
    const { payload } = await jwtVerify(token, keys, {
      issuer: 'https://auth.example.com',
      audience: 'my-app',
    });
    return payload.sub ?? null;
  } catch {
    return null;
  }
};

export const syncServer = createSyncServer({ schema, store: memoryStore(), auth: userOf });

The client sends the token on every request, asking for it each time so a refreshed one is used:

sync.tsClient
import { createSync, http, indexedDbStorage } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({
  schema,
  storage: indexedDbStorage('my-app'),
  backend: http({
    url: 'https://api.example.com/sync',
    headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
  }),
});

This is the shape of every hosted provider: Clerk, Auth0, WorkOS, Supabase, Firebase. Only the keys' URL and the claims change. See Supabase and Firebase for two of them end to end.

Gotchas

  • Answer null, do not throw, for a missing or invalid token: null is a clean 401 the client understands; a throw is a 500.
  • A user id must not change. If a provider can give the same person two ids (two sign-in methods), map them to one of yours before answering.
  • Sign-in and sign-out on the device are sync.connect and sync.reset: see Accounts.

On this page