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 without signing up. Jazz generates a secret on the client, derives a stable account ID from it, and uses self-signed tokens to prove the user owns that account. The secret itself effectively is the account.

This secret (and therefore the account) lives wherever the user uses the app. To log in from other devices, the user needs to use the same secret.

ModeIdentity sourceServer needed?Best for
local-firstKeypair held by clientNoProduction offline-first apps, try-before-signup flows
externalJWT from auth providerYesProduction apps with real user accounts

When to use local-first auth

If you want users to be able to start immediately, local-first (client setup) is a great option, but any user who clears site data (intentionally or otherwise) loses their account.

You can add a recovery passphrase or passkey backup which allows users to more easily log in on multiple devices or recover their accounts if they delete them. However, these are often less familiar and can add UX friction.

A good middle ground is starting users on local-first so they can play around immediately, but let them upgrade to a managed account when they want to, for example using Better Auth (see signing up with BetterAuth).

If you don't want a try-before-signup flow at all, skip local-first entirely and use external auth from day one.

You can also combine modes: let local-first users read and experiment, but require an upgrade before any sensitive action — see Permissions by auth mode.

Client setup

Fetch or create a secret and pass it to your Jazz client. Jazz stores the secret in the browser (or equivalent on native) so subsequent loads reuse the same account.

App.tsx
export function LocalFirstAuthApp() {
  const { secret, isLoading } = useLocalFirstAuth();

  if (isLoading || !secret) return null;

  return (
    <JazzProvider
      config={{
        appId: "my-app",
        secret,
      }}
    >
      <TodoApp />
    </JazzProvider>
  );
}

useLocalFirstAuth() also exposes login and signOut for switching or clearing accounts.

App.vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { createJazzClient, JazzProvider } from "jazz-tools/vue";
import { BrowserAuthSecretStore } from "jazz-tools";

const client = ref<ReturnType<typeof createJazzClient> | null>(null);

onMounted(async () => {
  const secret = await BrowserAuthSecretStore.getOrCreateSecret();
  client.value = createJazzClient({
    appId: "my-app",
    secret,
  });
});
</script>

<template>
  <JazzProvider v-if="client" :client="client">
    <slot />
  </JazzProvider>
</template>
App.svelte
<script lang="ts">
  import { BrowserAuthSecretStore, createJazzClient, JazzSvelteProvider } from 'jazz-tools/svelte';
  import type { Snippet } from 'svelte';

  let { children }: { children: Snippet } = $props();

  const client = BrowserAuthSecretStore.getOrCreateSecret().then((secret) =>
    createJazzClient({ appId: 'my-app', secret }),
  );
</script>

<JazzSvelteProvider {client}>
  {@render children()}
</JazzSvelteProvider>
jazz-client.ts
export async function createLocalFirstDb() {
  const secret = await BrowserAuthSecretStore.getOrCreateSecret({ appId: "my-app" });

  return createDb({
    appId: "my-app",
    secret,
  });
}

Lose the secret and you lose the account. If it's cleared (e.g. localStorage wiped), the account and any data owned by it become inaccessible unless the user restores from a recovery passphrase, a passkey backup, or an external provider they linked earlier.

For how the secret relates to local database storage, logout, and user switching, see Auth Lifecycle.

Backing up and restoring the secret

You can keep local-first auth fully serverless and still make it recoverable. Jazz ships two backup helpers:

MethodPlatformsBest forTrade-offs
jazz-tools/passphraseBrowser, ExpoManual backup users can carry anywhereUser must securely store a 24-word recovery passphrase, and UI has to convey the weight of this
jazz-tools/passkey-backupBrowser onlyFast recovery on devices that support passkeysPasskey sync is platform-bounded

Recovery passphrase

jazz-tools/passphrase encodes the secret as a 24-word English passphrase, similar to a crypto-wallet seed phrase. Decoding the passphrase produces the exact same secret, so the user keeps the same Jazz account.

The 24 words are just the secret encoded differently, not a password layered over it. There's no hashing, no key-wrapping, no challenge-response: whoever sees the passphrase can sign in as the user. This poses a difficult UX challenge: the phrase is too long for most people to remember, and the risk of insecure storage is high.

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

  return {
    isLoading,
    recoveryPhrase: secret ? RecoveryPhrase.fromSecret(secret) : null,
  };
}
export function useRecoveryPhraseBackup() {
  const isLoading = ref(true);
  const recoveryPhrase = ref<string | null>(null);

  onMounted(async () => {
    const secret = await BrowserAuthSecretStore.loadSecret();
    recoveryPhrase.value = secret ? RecoveryPhrase.fromSecret(secret) : null;
    isLoading.value = false;
  });

  return { isLoading, recoveryPhrase };
}
export function createRecoveryPhraseBackup() {
  let isLoading = $state(true);
  let recoveryPhrase = $state<string | null>(null);

  onMount(async () => {
    const secret = await BrowserAuthSecretStore.loadSecret();
    recoveryPhrase = secret ? RecoveryPhrase.fromSecret(secret) : null;
    isLoading = false;
  });

  return {
    get isLoading() {
      return isLoading;
    },
    get recoveryPhrase() {
      return recoveryPhrase;
    },
  };
}
export async function getRecoveryPhrase(): Promise<string | null> {
  const secret = await BrowserAuthSecretStore.loadSecret();
  return 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:

export function useRecoveryPhraseRestore(): (userInput: string) => Promise<void> {
  const { login } = useLocalFirstAuth();

  return async (userInput: string) => {
    const restoredSecret = RecoveryPhrase.toSecret(userInput);
    await login(restoredSecret);
  };
}
export function useRecoveryPhraseRestore() {
  return async (userInput: string) => {
    const restoredSecret = RecoveryPhrase.toSecret(userInput);
    await BrowserAuthSecretStore.saveSecret(restoredSecret);
    // Reload so the mounted JazzProvider picks up the restored secret.
    location.reload();
  };
}
export async function restoreFromRecoveryPhrase(userInput: string) {
  const restoredSecret = RecoveryPhrase.toSecret(userInput);
  await BrowserAuthSecretStore.saveSecret(restoredSecret);
  // Reload so the mounted JazzSvelteProvider picks up the restored secret.
  location.reload();
}
export async function restoreFromRecoveryPhrase(userInput: string): Promise<void> {
  const restoredSecret = RecoveryPhrase.toSecret(userInput);
  await BrowserAuthSecretStore.saveSecret(restoredSecret);
  // Reload so the live Jazz client picks up the restored secret.
  location.reload();
}

The parser is case-insensitive and tolerant of extra whitespace between words. Invalid input throws RecoveryPhraseError with codes like invalid-length, invalid-word, and invalid-checksum.

In React and Expo, useLocalFirstAuth().login() updates the mounted JazzProvider immediately. Other frameworks use BrowserAuthSecretStore.saveSecret() directly, which doesn't notify the live client — the snippets above reload the page so the new secret is picked up on the next load.

Passkey backup

A passkey is a credential stored and synced by the user's browser or platform (via the WebAuthn browser API), typically unlocked with biometrics. jazz-tools/passkey-backup uses this as an encrypted at-rest store for the secret: the secret is held inside a resident WebAuthn credential and released after a user verification prompt. This is not a full WebAuthn authentication flow — Jazz is not challenging the key, just using the passkey as a vault for the seed.

Passkey availability depends on where the user's passkey provider works. OS-native stores (iCloud Keychain, Google Password Manager) have platform boundaries that are not always visible to users. For example, a passkey created on Safari/macOS may not be available on Chrome/Windows unless the user is using a third-party passkey manager (1Password, Bitwarden, Dashlane etc.) that spans both. WebAuthn's cross-device flow (QR from laptop to phone) can bridge a session but requires the original device, so it doesn't help after a loss.

Treat passkey backup as one option among several. Pair it with a recovery passphrase or a linked provider account so the user doesn't get locked out.

Create the passkey from the current local-first secret:

const passkeyBackup = new BrowserPasskeyBackup({
  appName: "My App",
  // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
  // which scopes passkeys per preview-deploy URL.
  appHostname: "myapp.com",
});

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);
    },
  };
}
const passkeyBackup = new BrowserPasskeyBackup({
  appName: "My App",
  // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
  // which scopes passkeys per preview-deploy URL.
  appHostname: "myapp.com",
});

export function usePasskeyBackup() {
  return async (displayName: string) => {
    const secret = await BrowserAuthSecretStore.loadSecret();
    if (!secret) throw new Error("No local secret to back up yet");
    await passkeyBackup.backup(secret, displayName);
  };
}
const passkeyBackup = new BrowserPasskeyBackup({
  appName: "My App",
  // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
  // which scopes passkeys per preview-deploy URL.
  appHostname: "myapp.com",
});

export async function backupToPasskey(displayName: string) {
  const secret = await BrowserAuthSecretStore.loadSecret();
  if (!secret) throw new Error("No local secret to back up yet");
  await passkeyBackup.backup(secret, displayName);
}
const passkeyBackup = new BrowserPasskeyBackup({
  appName: "My App",
  // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
  // which scopes passkeys per preview-deploy URL.
  appHostname: "myapp.com",
});

export async function backupToPasskey(displayName: string): Promise<void> {
  const secret = await BrowserAuthSecretStore.loadSecret();
  if (!secret) throw new Error("No local secret to back up yet");
  await passkeyBackup.backup(secret, displayName);
}

Restore by reading the secret back from the passkey:

export function usePasskeyRestore(): () => Promise<void> {
  const { login } = useLocalFirstAuth();

  return async () => {
    const restoredSecret = await passkeyBackup.restore();
    await login(restoredSecret);
  };
}
export function usePasskeyRestore() {
  return async () => {
    const restoredSecret = await passkeyBackup.restore();
    await BrowserAuthSecretStore.saveSecret(restoredSecret);
    location.reload();
  };
}
export async function restoreFromPasskey() {
  const restoredSecret = await passkeyBackup.restore();
  await BrowserAuthSecretStore.saveSecret(restoredSecret);
  location.reload();
}
export async function restoreFromPasskey(): Promise<void> {
  const restoredSecret = await passkeyBackup.restore();
  await BrowserAuthSecretStore.saveSecret(restoredSecret);
  location.reload();
}

Passkey backup is currently browser-only. Mobile platform support is planned.

  • appHostname becomes the WebAuthn relying-party ID which is the namespace passkeys are scoped to. If you change it, all existing passkeys will stop working.

  • The displayName argument to backup(secret, displayName) is the user-visible name shown in 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 the cloud, and in local dev mode. For self-hosted production setups, 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 Jazz account, now with an email (or OAuth identity) attached.

The client generates a short-lived proof that it owns the current Jazz account and hands it to BetterAuth alongside the sign-up credentials. BetterAuth verifies the proof, records the Jazz account ID against the new user, and issues JWTs that keep pointing to the same ID. For the reasoning and sequence diagram, see Upgrading to a provider account.

Generating the proof token

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

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>;
}

audience is application-defined; the string only needs to match what the server passes when verifying. ttlSeconds keeps the window short — 60 seconds is usually 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:

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:

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:

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

Sketched in pseudo-Express (db.users.create and issueJwt stand in for whatever your stack provides):

Illustrative — not a runnable example
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 reads the JWT sub claim verbatim as session.user_id — no custom-claim fallback — so your provider needs to emit the Jazz ID as sub. If it doesn't let you override sub (e.g. it pins it to its own user ID), you'll need to mint the JWT yourself rather than using the provider's issuer.

Next steps

On this page