Auth & Permissions

Local-first auth

Authenticate users without a server using self-signed tokens, and optionally upgrade to an external provider while preserving identity.

Local-first auth lets users start using your app immediately — no sign-up, no server round-trip. Jazz generates a stable identity from a secret stored on the device. Without a recovery mechanism, that identity lives only on the device — if the secret is lost, it's gone. Linking to an external provider at sign-up is one way to make the identity durable and recoverable.

ModeIdentity sourceServer needed?Best for
local-firstSecret stored on deviceNoProduction offline-first apps, try-before-signup flows
externalJWT from auth providerYesProduction apps with real user accounts

Client setup

Use useLocalFirstAuth() to manage the user's secret. On first load it generates a new identity; on subsequent loads it reuses the stored one. It also exposes login and signOut for switching or clearing the local identity.

App.tsx
import { JazzProvider, useLocalFirstAuth } from "jazz-tools/react";

function App() {
  const { secret, isLoading } = useLocalFirstAuth();

  if (isLoading || !secret) return <p>Loading…</p>;

  return (
    <JazzProvider
      config={{
        appId: "my-app",
        serverUrl: "wss://your-jazz-server.example.com",
        secret,
      }}
    >
      <YourApp />
    </JazzProvider>
  );
}

The secret is the user's identity. If it's lost (e.g. localStorage cleared), the identity is gone and any data owned by that user becomes inaccessible unless you restore it from a recovery passphrase, a passkey backup, or an external provider you linked earlier.

Backing up and restoring the secret

You can keep local-first auth fully serverless and still make it recoverable. Jazz ships two backup helpers for the same 32-byte secret:

MethodPlatformsBest forTrade-offs
jazz-tools/passphraseBrowser, ExpoManual backup users can carry anywhereUser must safely store a 24-word recovery passphrase
jazz-tools/passkey-backupBrowser onlyFast recovery on devices that support passkeysRequires WebAuthn and a stable relying-party ID

Recovery passphrase

jazz-tools/passphrase converts the local-first secret into a 24-word English recovery passphrase. Restoring that passphrase produces the exact same secret, so the user keeps the same Jazz identity.

auth-snippets.tsx
export function useRecoveryPhraseBackup(): {
  isLoading: boolean;
  recoveryPhrase: string | null;
} {
  const { secret, isLoading } = useLocalFirstAuth();

  return {
    isLoading,
    recoveryPhrase: secret ? RecoveryPhrase.fromSecret(secret) : null,
  };
}
auth-snippets.tsx
export function useRecoveryPhraseBackup(): {
  isLoading: boolean;
  recoveryPhrase: string | null;
} {
  const { secret, isLoading } = useLocalFirstAuth();

  return {
    isLoading,
    recoveryPhrase: secret ? RecoveryPhrase.fromSecret(secret) : null,
  };
}

To restore, decode the user-provided passphrase back into a secret and hand it back to your local-first auth flow:

auth-snippets.tsx
export function useRecoveryPhraseRestore(): (userInput: string) => Promise<void> {
  const { login } = useLocalFirstAuth();

  return async (userInput: string) => {
    const restoredSecret = RecoveryPhrase.toSecret(userInput);
    await login(restoredSecret);
  };
}
auth-snippets.tsx
export function useRecoveryPhraseRestore(): (userInput: string) => Promise<void> {
  const { login } = useLocalFirstAuth();

  return async (userInput: string) => {
    const restoredSecret = RecoveryPhrase.toSecret(userInput);
    await login(restoredSecret);
  };
}

The parser accepts upper-case input and extra whitespace. Invalid input throws RecoveryPhraseError with codes like invalid-length, invalid-word, and invalid-checksum.

In React and Expo, prefer useLocalFirstAuth() for backup and restore so the mounted JazzProvider updates immediately. The lower-level BrowserAuthSecretStore and ExpoAuthSecretStore APIs are still useful outside React-based UIs.

Passkey backup

For browser apps, jazz-tools/passkey-backup stores the same secret in a resident WebAuthn credential. That gives users passkey-style recovery without adding a server-side auth provider.

Create the passkey from the current local-first secret:

auth-snippets.tsx
export function usePasskeyBackup(): {
  isLoading: boolean;
  backupWithPasskey: (displayName: string) => Promise<void>;
} {
  const { secret, isLoading } = useLocalFirstAuth();

  return {
    isLoading,
    backupWithPasskey: async (displayName: string) => {
      if (!secret) {
        throw new Error("Local-first secret is not ready yet");
      }

      await passkeyBackup.backup(secret, displayName);
    },
  };
}

Restore from the passkey by reading the secret back and passing it to login(...) from useLocalFirstAuth():

auth-snippets.tsx
export function usePasskeyRestore(): () => Promise<void> {
  const { login } = useLocalFirstAuth();

  return async () => {
    const restoredSecret = await passkeyBackup.restore();
    await login(restoredSecret);
  };
}

Use a stable appHostname everywhere you want passkey recovery to work. It becomes the WebAuthn relying-party ID, so changing it creates a different passkey namespace. backup(secret, displayName) also needs a user-visible name for the platform passkey sheet.

Handle PasskeyBackupError in your UI to surface cases like unsupported browsers, no saved credential, or missing user verification. Keep a recovery passphrase or external sign-in as a fallback if the user might lose access to their passkey provider.

Server configuration

Local-first auth is enabled by default in development. In production, enable it explicitly:

pnpm dlx jazz-tools@alpha server <APP_ID> --allow-local-first-auth

No JWKS endpoint is needed — the token is self-contained and the server can verify it on its own.

Signing up with BetterAuth

Users can try your app before creating an account. When they sign up later through BetterAuth, all the data they created carries over — same account, nothing lost.

Generating the proof token

Before calling your sign-up endpoint, the client generates a short-lived proof that it owns the current Jazz identity:

SignUp.tsx
function SignUpButton() {
  const db = useDb();

  async function handleSignUp(email: string, password: string) {
    // Generate proof of ownership of the current Jazz identity
    const proofToken = await db.getLocalFirstIdentityProof({
      ttlSeconds: 60,
      audience: "betterauth-signup",
    });

    if (!proofToken) {
      throw new Error("Sign up requires an active Jazz session");
    }

    const res = await authClient.signUp.email({
      email,
      name: email,
      password,
      proofToken,
    } as Parameters<typeof authClient.signUp.email>[0]);

    if (res.error) {
      throw new Error(res.error.message);
    }
  }

  return <button onClick={() => handleSignUp("user@example.com", "password")}>Sign Up</button>;
}

The audience should match what the server expects when verifying. The ttlSeconds keeps the window short — 60 seconds is enough for a sign-up round-trip.

Verifying on sign-up

On the server, a BetterAuth middleware hook intercepts sign-up requests, verifies the proof token, and assigns the proven Jazz user ID to the new BetterAuth user:

src/lib/auth.ts
export const auth = betterAuth({
  // ...your database, email, plugins config
  plugins: [
    jwt({
      jwks: { keyPairConfig: { alg: "ES256" } },
      jwt: {
        issuer: "https://your-app.example.com",
        definePayload: ({ user }) => ({
          claims: { role: (user as { role?: string }).role ?? "" },
        }),
      },
    }),
  ],
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path !== "/sign-up/email") return;

      const { verifyLocalFirstIdentityProof } = await import("jazz-napi");
      const {
        ok,
        error,
        id: provedUserId,
      } = verifyLocalFirstIdentityProof(ctx.body?.proofToken, "betterauth-signup");
      if (!ok) {
        throw new APIError("BAD_REQUEST", { message: error });
      }

      return {
        context: {
          ...ctx,
          body: { ...ctx.body, provedUserId },
        },
      };
    }),
  },
  databaseHooks: {
    user: {
      create: {
        before: async (user: any, ctx: any) => {
          // Assign the proven Jazz user ID to the BetterAuth user
          const provedUserId = ctx?.body?.provedUserId;
          if (provedUserId) {
            return { data: { ...user, id: provedUserId } };
          }
        },
      },
    },
  },
});

verifyLocalFirstIdentityProof from jazz-napi handles validation — it returns { ok, id, error }. If the token is missing or invalid, error contains the reason. The databaseHooks.user.create.before hook assigns the proven ID to the new BetterAuth user, so the identity carries over.

Config resolution

The app switches between local-first auth and external JWT based on whether the user has a BetterAuth session:

App.tsx
function useBetterAuthJWT() {
  const { data, isPending } = authClient.useSession();
  const [jwt, setJwt] = useState<string | null>(null);
  const [isFetching, setIsFetching] = useState(false);

  useEffect(() => {
    if (isPending) return;
    if (!data?.session) {
      setJwt(null);
      return;
    }
    setIsFetching(true);
    void getJwtFromBetterAuth().then((token) => {
      setJwt(token ?? null);
      setIsFetching(false);
    });
  }, [isPending, data?.session?.id]);

  return {
    isLoading: isPending || isFetching,
    jwt,
    getRefreshedJWT: () => getJwtFromBetterAuth(),
  };
}

function App() {
  const betterAuth = useBetterAuthJWT();
  const { secret: localFirstSecret, isLoading: localFirstLoading } = useLocalFirstAuth();

  // Only mint a local-first secret when there's no BetterAuth session.
  const secret = !betterAuth.jwt ? (localFirstSecret ?? undefined) : undefined;

  const config = useMemo<DbConfig>(
    () => ({
      appId: process.env.NEXT_PUBLIC_JAZZ_APP_ID!,
      serverUrl: process.env.NEXT_PUBLIC_JAZZ_SERVER_URL!,
      jwtToken: betterAuth.jwt ?? undefined,
      secret,
    }),
    [betterAuth.jwt, secret],
  );

  if (betterAuth.isLoading || (!betterAuth.jwt && localFirstLoading)) return <p>Loading auth…</p>;

  return (
    <JazzProvider
      config={config}
      onJWTExpired={() => betterAuth.getRefreshedJWT()}
      fallback={<p>Loading Jazz DB…</p>}
    >
      <YourApp />
    </JazzProvider>
  );
}

Permissions by auth mode

You can restrict what local-first users can do until they sign up. Define an isLocalFirstAuthMode check in your permissions and use it to gate writes:

permissions.ts
export default definePermissions(app, ({ policy, allOf, session }) => {
  const isLocalFirstAuthMode = session.where({
    authMode: "local-first",
  });

  // Everyone can read
  policy.messages.allowRead.always();

  // Local-first users can only read — block inserts until they sign up
  policy.messages.allowInsert.where(allOf([{ not: isLocalFirstAuthMode }]));
});

Signing up with other providers

The BetterAuth example above uses a framework-specific middleware hook. With any other provider the pattern is the same — you need a server endpoint that:

  1. Accepts proofToken alongside the sign-up credentials
  2. Verifies it with verifyLocalFirstIdentityProof from jazz-napi
  3. Stores the proven Jazz user ID linked to the new user account
  4. Issues JWTs with sub: jazzId
server.ts
import { verifyLocalFirstIdentityProof } from "jazz-napi";

// POST /signup
app.post("/signup", async (req, res) => {
  const { email, password, proofToken } = req.body;

  const {
    ok,
    id: jazzUserId,
    error,
  } = verifyLocalFirstIdentityProof(
    proofToken,
    "my-app-signup", // must match the audience used on the client
  );
  if (!ok) return res.status(400).json({ error });

  // Store jazzUserId so you can mint future JWTs with it as `sub`.
  const user = await db.users.create({ email, password, jazzId: jazzUserId });

  res.json({ token: issueJwt({ sub: user.jazzId /* ... */ }) });
});

The audience string on the client and server must match. jazz-napi normalizes it internally, so any consistent string works.

Jazz uses the JWT sub claim as session.user_id. If your provider keeps sub fixed to its own user ID, add a /link-jazz-identity endpoint that stores the Jazz ID and mint the JWT with sub: <jazzId> yourself — either via a custom getSubject hook on your provider or by issuing the JWT directly.

Under the hood

Local-first auth uses Ed25519 cryptography to derive a stable identity from a secret:

  1. A 32-byte seed is hashed with SHA-512 to produce an Ed25519 signing key.
  2. The corresponding public key (32 bytes) is extracted.
  3. A deterministic user ID is derived from the public key using UUIDv5 (namespace jazz-auth-key-v1).
  4. The client mints a self-signed JWT with:
    • alg: "EdDSA" — Ed25519 signature
    • iss: "urn:jazz:local-first" — identifies the token as local-first
    • sub: <user_id> — the derived user ID
    • jazz_pub_key: <base64url> — the embedded public key
    • aud: <app_id> — scoped to the app

The server verifies the signature using the embedded public key, re-derives the user ID, and confirms it matches sub. No external key store is involved — the token is fully self-contained.

Same seed always produces the same user ID, across devices and time.

Next steps

On this page