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.tsimport { 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 verifyreturn 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.tsimport { 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: Bearerheader. Passtoken: (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). Passclaims: (payload) => ({ role: payload.role })to choose. - Audience: required. Name the audience your provider puts in
audfor this app, so a token issued to another app on the same tenant is refused. Passaudience: falseonly when your issuer is already specific to this app and its tokens carry noaud(WorkOS, for example). Writefalseliterally: a computed value such asisProd && "my-app"turns the check off outside production. - Expiry: tokens must carry
exp.expandnbfare checked with 60 seconds of leeway for clock skew; change it withclockToleranceSeconds. - Keys: pass
publicKey(PEM, X.509 or a JWK) instead ofjwksUrlif 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:
fromJWTcaches 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
authenticatestops 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 callreauthenticate()so the client asks your backend again.
How it works
A browser signing in to Jazz through the app's backend.
- The user signs in with your auth library, which sets its own session cookie.
- The client asks
/jazzwho is signed in (afterreauthenticate(), on start, on focus, or when another tab signs in). Your backend runsauthenticateand returns the user id. - Jazz gives that user an account the first time, and the same account every time after. The
identity is
{ issuer: "jazz:backend", subject: userId }. - Every sync connection asks
authenticateagain, 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.tss.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.