Other / Custom

Learn how to connect any other auth library, or your own sign-in, to Jazz.

Jazz doesn't talk to your auth provider. Your backend does, and tells Jazz who each request's user is. Whatever you use (Clerk, Auth0, Supabase Auth, Firebase, Lucia, your own sessions), the shape is the same: a function that reads your session and returns a user id.

jazz.server.ts
import { createJazz, remote } from "jazz-tools/backend";
import { app } from "./schema";
import permissions from "./permissions";
import { getUser } from "./your-auth";
​
export const jazz = createJazz({
app,
permissions,
store: remote({
appId: process.env.JAZZ_APP_ID!,
url: process.env.JAZZ_SERVER_URL!,
secret: process.env.JAZZ_ADMIN_SECRET!, // the app's admin secret
}),
authenticate: async (request) => {
const user = await getUser(request); // your cookie, or a bearer token you verify
return user ? { userId: user.id, claims: { role: user.role } } : null;
},
});

Mount it under /jazz (see Main Backend). The browser talks only to your backend; the core behind remote() trusts your backend to say who the user is, and never sees your users' credentials.

Tokens from your identity provider

If your provider gives you a JWT (WorkOS access tokens, Auth0, Clerk, Supabase, any OIDC issuer), fromJWT is a ready-made authenticate. It verifies the token's signature, expiry, issuer and audience, names the user by its sub, and passes the token's other claims through:

jazz.server.ts
import { createJazz, fromJWT, remote } from "jazz-tools/backend";
​
export const jazz = createJazz({
app,
permissions,
store: remote({ url, appId, secret }),
authenticate: fromJWT({
jwksUrl: "https://idp.example.com/.well-known/jwks.json",
issuer: "https://idp.example.com",
audience: "my-app",
}),
});
  • Where the token comes from: by default the Authorization: Bearer header. Pass token: (request) => ... to read it from a cookie or your session instead. Browser clients send cookies, not bearer headers, so an app whose browsers held the JWT stores it in an httpOnly cookie at sign-in and reads it back here.
  • Which claims your policies see: by default every claim except the registered ones (iss, sub, aud, exp, nbf, iat, jti). Pass claims: (payload) => ({ role: payload.role }) to choose.
  • Audience: required. Name the audience your provider puts in aud for this app, so a token issued to another app on the same tenant is refused. Pass audience: false only when your issuer is already specific to this app and its tokens carry no aud (WorkOS, for example). Write false literally: a computed value such as isProd && "my-app" turns the check off outside production.
  • Expiry: tokens must carry exp. exp and nbf are checked with 60 seconds of leeway for clock skew; change it with clockToleranceSeconds.
  • Keys: pass publicKey (PEM, X.509 or a JWK) instead of jwksUrl if your provider gives you a key rather than a JWKS endpoint.

A request with no token, or with one that doesn't verify, is signed out. A JWKS that can't be fetched throws instead, so clients retry rather than signing out.

  • Key caching: fromJWT caches the JWKS for 5 minutes, so a key your provider drops keeps verifying tokens until the cache expires. A token signed with a key the cache doesn't know yet makes it fetch again; within 30 seconds of the last fetch it throws instead, so the client retries shortly rather than signing out.
  • Revoking a session: once authenticate stops accepting a user's token, their open sync sessions end at the next check (recheckSeconds). A tab that stays in view then shows them signed out about 32 seconds after the refusal: a 30-second grace in case the refusal was a blip, plus a 2-second confirmation.
  • Refreshing short-lived tokens: store the new token where token(request) reads it, such as your cookie, then call reauthenticate() so the client asks your backend again.

How it works

A browser signing in to Jazz through the app's backend.

Sign in (your authlibrary)Session cookiePOST/jazz/.../backend-sign-inauthenticate(request)Log in or registerthis userAccount, identity,claimsBrowserYour backendJazz core
  1. The user signs in with your auth library, which sets its own session cookie.
  2. The client asks /jazz who is signed in (after reauthenticate(), on start, on focus, or when another tab signs in). Your backend runs authenticate and returns the user id.
  3. Jazz gives that user an account the first time, and the same account every time after. The identity is { issuer: "jazz:backend", subject: userId }.
  4. Every sync connection asks authenticate again, so a sign-out or changed claims apply on the next reconnect.

In the client

With React, JazzProvider follows whoever your backend names. After your own sign-in or sign-out, call reauthenticate() from useJazzAuth():

import { JazzProvider, useJazzAuth } from "jazz-tools/react";
​
<JazzProvider signedOut={<SignIn />}>
<App />
</JazzProvider>;
​
function SignOutButton() {
const { reauthenticate } = useJazzAuth();
return <button onClick={async () => (await signOut(), await reauthenticate())}>Sign out</button>;
}

Jazz also asks your backend when the app starts, when the page regains focus and when another tab signs in or out.

Without a framework, call reauthenticate() on the session:

import { createJazzClient } from "jazz-tools/client";
​
const jazz = await createJazzClient({});
await jazz.reauthenticate(); // after your own sign-in or sign-out

Svelte, Solid, Vue and React Native expose the same actions through their bindings.

Guests who sign up

An app can start every visitor as a local-first guest (initial: "local-first"). Its backend lets guests sync by returning acceptLocalFirstAuth(request) from authenticate for signed-out requests; guests are refused otherwise:

authenticate: async (request) => {
const user = await yourAuth.userFor(request);
return user ? { userId: user.id } : acceptLocalFirstAuth(request);
},

When the guest signs up, call linkWithBackend() in your sign-up handler:

await yourAuth.signUp(email, password); // sets your session cookie
// Only a device with a guest has something to link.
if (jazz.getSnapshot().status === "ready") await jazz.linkWithBackend();
else await jazz.reauthenticate();

Without a guest on this device, linkWithBackend() rejects with "Linking requires a logged-in account"; reauthenticate() signs the new user in instead.

The guest proves itself with its own local-first key; your backend checks that the user authenticate names has no Jazz account yet and links the two. From then on, signing in as that user opens the guest's account and everything it wrote. An identity belongs to one account only, so linking a user who already has an account fails: Jazz doesn't merge accounts.

The session links the guest on its own too, whenever it finds a signed-in user who has no Jazz account yet: after reauthenticate(), when the page regains focus after a sign-up popup, or on the first check after a sign-up redirect. Asking who is signed in never creates an account, so a sign-up can't race the link. A linkWithBackend() call that arrives while that link runs, or after it, joins it. If your backend refuses the link (the guest's key was retired, or the user got an account of their own meanwhile), the session signs the user in to their own account instead and never retries that link; the guest stays on this device.

"No Jazz account yet" includes an existing user of your app who signs in to Jazz for the first time. On a shared device, where the guest may be someone else's, that user would get the guest's data. Pass linkGuestOnSignUp: false to createJazzClient (or <JazzProvider>) to stop the session linking on its own. It then leaves such a guest as it is, and your app decides after its own sign-in: linkWithBackend() to give the user the guest's data, or loginWithBackend() for an account of their own (the guest stays on this device).

To link from your own backend code instead (a second sign-in method, an anonymous id your app issued), call jazz.linkIdentity({ from, to }).

Using claims in permissions

claims from authenticate are available to your policies under session.claims.

permissions.ts
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
policy.todos.allowRead.where(
anyOf([{ owner_id: session.user }, session.where({ "claims.role": "manager" })]),
);
});

See Declarative Permissions for the full claims API.

Ownership and provenance

session.user is the account, and session.identity is the exact login: { issuer: "jazz:backend", subject: "alice" }, where subject is the userId your authenticate returned. Author columns ($createdBy, $updatedBy) hold both:

{
account: "00000000-0000-4000-8000-000000000001",
identity: { issuer: "jazz:backend", subject: "alice" },
}

Compare session.user for account-owned data, such as { $createdBy: session.user }. Two linked identities (a guest and the user it became) share account ownership while keeping distinct authorship.

Users from alpha.59

Apps on alpha.59 signed users in with JWTs, so their accounts belong to identities like { issuer: "https://idp.example.com", subject: "user_01" }. createJazz names users { issuer: "jazz:backend", subject: userId } instead. The alpha.59 import (jazz-alpha59-alpha60-import import, a binary on the release's GitHub page) links each user's jazz:backend identity to their existing account. Its --app-id must be exactly the app ID your alpha.59 app used, which is the appId you pass to remote() on Jazz Cloud. With sqlite() the import writes it into the data directory, so your code doesn't pass it. If your authenticate returns the old token's sub as userId (which fromJWT does), returning users open their old accounts and keep all their data. Pass --no-backend-identity-links to skip this.

Two issuers that used the same sub for different accounts can't both map to one userId. The import then stops and names the issuers that conflict. Your provider's sub must stay the same for each user: fromJWT checks that every token comes from the one issuer you configured.

Backend request contexts

await jazz.forReqUser(request) runs your authenticate and returns a database with that user's policies and authorship; jazz.db acts with backend authority. See Main Backend.

Only a sign-in from a Jazz client (step 3 above), a guest link or jazz.createAccountFor(userId) gives a user an account. For a user who has none yet, forReqUser throws NoJazzAccountError (code: "no_account"). Users of a REST or API-only server never open a client, so call createAccountFor in your sign-in route; see Users without an account.

Full working examples: Better Auth chat, simple auth chat, WorkOS chat.