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.
| Mode | Identity source | Server needed? | Best for |
|---|---|---|---|
local-first | Keypair held by client | No | Production offline-first apps, try-before-signup flows |
external | JWT from auth provider | Yes | Production 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.
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.
<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><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>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:
| Method | Platforms | Best for | Trade-offs |
|---|---|---|---|
jazz-tools/passphrase | Browser, Expo | Manual backup users can carry anywhere | User must securely store a 24-word recovery passphrase, and UI has to convey the weight of this |
jazz-tools/passkey-backup | Browser only | Fast recovery on devices that support passkeys | Passkey 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.
-
appHostnamebecomes the WebAuthn relying-party ID which is the namespace passkeys are scoped to. If you change it, all existing passkeys will stop working. -
The
displayNameargument tobackup(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-authNo 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:
- Accepts
proofTokenalongside the sign-up credentials - Verifies it with
verifyLocalFirstIdentityProoffromjazz-napi - Stores the proven Jazz user ID linked to the new user account
- Issues JWTs with
sub: jazzId
Sketched in pseudo-Express (db.users.create and issueJwt stand in for whatever your stack provides):
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
- Lifecycle — local-first storage, logout, and upgrading auth
- Sessions — read the current user and scope queries to their identity
- Permissions — define row-level access policies
- Auth provider integration — set up BetterAuth or WorkOS as your JWT provider
- Local-first auth internals — token format, verification, and design rationale