Auth provider integration
Connect an external auth provider to Jazz with JWT validation, with examples for Better Auth and WorkOS.
Jazz supports external JWT-based authentication for production use. This recipe walks through connecting a provider to Jazz, with examples for Better Auth (self-hosted) and WorkOS (managed service).
How it works
Provider sign-in
An external JWT provider connecting to a Jazz server.
- The user signs in with your auth provider and gets a JWT.
- The shared provider connection calls
loginOrRegisterJWT({ getToken }). - The Jazz server validates the JWT signature against the provider's JWKS endpoint.
- Core atomically resolves the exact
(iss, sub)assignment or creates its account when fresh. The Jazz session opens the account's client.session.usercontains the account ID and exact acting identity; provider claims remain undersession.claims. Revoked assignments remain rejected; login-or-register never reassigns an identity.
If you're unfamiliar with JWTs and JWKS, see the explainer on the Authentication page.
Provider setup
Better Auth is a self-hosted auth framework. You run the server yourself and enable the jwt plugin, which exposes a JWKS endpoint and issues signed JWTs.
Server
export const auth = betterAuth({
// your database, email config, etc.
plugins: [
jwt({
jwks: {
keyPairConfig: { alg: "ES256" },
},
jwt: {
issuer: "https://your-app.example.com",
definePayload: ({ user }) => ({ role: (user as { role?: string }).role ?? "" }),
},
}),
],
});This exposes a JWKS endpoint at /api/auth/jwks (or wherever you mount Better Auth's handler).
Client
Create the auth client with the jwtClient plugin so you can request JWT tokens.
export const authClient = createAuthClient({
plugins: [jwtClient()],
});Connecting to Jazz
Pass auth={betterAuth(authClient)} to JazzProvider. Forms only call Better Auth signup or sign-in; Jazz owns startup, account changes and error recovery. Use useJazzAuth().logout() to flush Jazz before revoking the provider session.
export function App() {
return (
<JazzProvider
appId="my-app"
serverUrl="wss://your-jazz-server.example.com"
auth={betterAuth(authClient)}
signedOut={<p>Sign in to continue.</p>}
>
<YourApp />
</JazzProvider>
);
}
// Forms only call authClient.signUp / signIn. Jazz follows automatically.Jazz server configuration
Configure the reachable JWKS endpoint and the exact issuer and audience emitted by Better Auth:
pnpm dlx jazz-tools@alpha server <APP_ID> \
--jwks-url https://your-app.example.com/api/auth/jwks \
--jwt-issuer https://your-app.example.com \
--jwt-audience https://your-app.example.comBetter Auth starters use APP_ORIGIN for baseURL, JWT issuer/audience, and the Jazz server's
jwksUrl, jwtIssuer, and jwtAudience. Set it consistently in the environment shared by
the app and auth server. The React/Vite and TypeScript starters default to the auth server
origin (http://localhost:3001), not the frontend's port 5173. A JWKS URL alone is not enough:
missing or mismatched issuer/audience settings cause account login to return
invalid account credential, even when Better Auth sign-in succeeds. Jazz Cloud must be able
to reach your JWKS endpoint; a local localhost URL is not reachable from the cloud.
For a full working example, see the Better Auth chat example.
If your users start unauthenticated and sign up later, see Local-first auth for how to preserve their identity across the transition.
WorkOS is a managed auth service — no server-side auth code needed. Wrap your app with AuthKitProvider, get the access token, and pass it to Jazz.
function JazzWithWorkOS() {
const { user, isLoading, getAccessToken, signIn, signOut } = useAuth();
return (
<JazzProvider
appId="my-app"
serverUrl="wss://your-jazz-server.example.com"
auth={jwtAuth({
key: user?.id ?? null,
isPending: isLoading,
getToken: () => getAccessToken({ forceRefresh: true }),
logout: () => signOut(),
})}
signedOut={<button onClick={() => void signIn()}>Sign in</button>}
>
<YourApp />
</JazzProvider>
);
}
export function App() {
return (
<AuthKitProvider clientId="client_01ABC...">
<JazzWithWorkOS />
</AuthKitProvider>
);
}Jazz server configuration
Point the Jazz server at the WorkOS JWKS endpoint:
pnpm dlx jazz-tools@alpha server <APP_ID> \
--jwks-url https://api.workos.com/sso/jwks/client_01ABC... \
--jwt-issuer <JWT_ISSUER> --jwt-audience <JWT_AUDIENCE>For a full working example, see the WorkOS chat example.
See Server setup for the full set of server flags.
Using JWT claims in permissions
Your auth provider's JWT may include custom claims (roles, organisation IDs, etc.). Access them in permissions via session.where(...).
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
policy.todos.allowRead.where(
anyOf([{ owner_id: session.user.account }, session.where({ "claims.role": "manager" })]),
);
});See Permissions for the full claims API.
Other providers
Any provider that issues JWTs and exposes a JWKS endpoint will work. The key pattern is always the same: get a JWT from your provider, pass it to Jazz.
| Provider | JWKS endpoint | sub claim format |
|---|---|---|
| Better Auth | <baseURL>/api/auth/jwks | User ID |
| WorkOS | https://api.workos.com/sso/jwks/<clientId> | user_<id> |
| Clerk | https://<app>.clerk.accounts.dev/.well-known/jwks.json | user_<id> |
| Auth0 | https://<tenant>.auth0.com/.well-known/jwks.json | auth0|<id> |
| Firebase | https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com | Firebase UID |
Jazz records the exact JWT iss and sub pair as the acting identity. Providers keep their existing subjects. Use linkJWT to associate a fresh identity with an existing account; use application rows for profile data.
Full working examples: Better Auth chat, WorkOS chat.