Better Auth
Learn how to sign users in with Better Auth and connect its session to Jazz.Better Auth is a self-hosted auth framework. It runs in the same
backend as Jazz, so authenticate asks it directly who the request's session cookie belongs to.
No JWT plugin, JWKS or bearer tokens are involved.
Server
jazz.server.tsimport { betterAuth } from "better-auth";import { createJazz, sqlite } from "jazz-tools/backend";import { app } from "./schema";import permissions from "./permissions";export const auth = betterAuth({// database: your own, or Jazz itself (see "Store Better Auth's tables in Jazz" below)emailAndPassword: { enabled: true },});export const jazz = createJazz({app,permissions,store: sqlite({ path: "./data/jazz" }),// The Better Auth session cookie decides who the user is.authenticate: async (request) => {const session = await auth.api.getSession({ headers: request.headers });return session ? { userId: session.user.id } : null;},});
Mount Jazz under /jazz next to Better Auth's own handler, which serves /api/auth/* by default.
In Next.js:
app/api/auth/[...all]/route.tsimport { toNextJsHandler } from "better-auth/next-js";import { auth } from "@/jazz.server";export const { GET, POST } = toNextJsHandler(auth);
For the /jazz route and other frameworks, see Your Own Server.
Pass anything your policies need, such as the admin plugin's role, as claims.
Act as the signed-in user
In a route handler, act as the signed-in user with jazz.forReqUser(request), which runs the same authenticate:
messages.tsimport { jazz } from "./jazz.server";import { app } from "./schema";export async function messages(request: Request) {const db = await jazz.forReqUser(request);return Response.json(await db.all(app.messages, { tier: "remote" }));}
This keeps the signed-in user's policies and authorship without switching your main backend's own identity. For account-owned rows, compare ownership against session.user in permissions; a Better Auth user ID is not a Jazz account UUID. Never accept a raw account ID from a request as authority.
Client
The plain Better Auth client is enough. Import it from your framework's entry:
better-auth/react, better-auth/vue, better-auth/svelte, or better-auth/client for plain
TypeScript.
src/lib/auth-client.tsimport { createAuthClient } from "better-auth/react"; // or "better-auth/vue", "better-auth/svelte"export const authClient = createAuthClient();
Behind a dev server proxy
When a separate frontend dev server (Vite, say) proxies /api/auth and /jazz to your backend,
Better Auth sees requests from the dev server's origin and answers 403 INVALID_ORIGIN. Add that
origin to trustedOrigins:
export const auth = betterAuth({trustedOrigins: ["http://localhost:5173"],// ...});
Connecting to Jazz
JazzProvider asks your backend who is signed in, so forms only call Better Auth's sign-up or
sign-in and then reauthenticate(). Jazz owns startup, account changes and error recovery, and
also checks again on start, on focus and when another tab signs in or out.
App.tsximport { JazzProvider, useJazzAuth } from "jazz-tools/react";import { authClient } from "./auth-client";function Root() {return (<JazzProvider signedOut={<SignIn />}><App /></JazzProvider>);}function SignIn() {const { reauthenticate } = useJazzAuth();async function signIn(email: string, password: string) {await authClient.signIn.email({ email, password });await reauthenticate();}// ...your form calls signIn}
For a full working example, including Better Auth's tables stored in Jazz, see the Better Auth chat example.
Store Better Auth's tables in Jazz
The Jazz Better Auth adapter lets Better Auth keep its users, credentials and sessions in the same Jazz app as your own data, so one main backend serves both. For general Better Auth setup (route handlers, client, plugins), see the Better Auth documentation.
Generate the tables
Better Auth tables live in a generated schema-better-auth/ module that your app schema spreads into its own table map. This keeps Better Auth's tables in the same Jazz app as your own data, so a single backend context handles both.
Start with shared Better Auth options.
auth-options.tsexport const authOptions = {baseURL: process.env.BETTER_AUTH_URL!,secret: process.env.BETTER_AUTH_SECRET!,emailAndPassword: { enabled: true },};
Set BETTER_AUTH_URL to your application server's origin and BETTER_AUTH_SECRET to a private Better Auth secret. Keep plugins, additional fields, and model customizations in these shared options so generation and runtime use the same configuration.
Create a separate configuration for generation. It does not import your app schema or open a Jazz session, so it works before either the generated file or Jazz exists. The CLI only requests schema generation; the database callback is deliberately unavailable.
auth-generate.tsimport { betterAuth } from "better-auth";import { schema as s } from "jazz-tools";import { jazzAdapter } from "jazz-tools/better-auth-adapter";import { authOptions } from "./auth-options";export const auth = betterAuth({...authOptions,database: jazzAdapter({db: async () => {throw new Error("The generation config cannot query the database");},schema: s.defineApp({}, { migrations: "none" }).wasmSchema,}),});
Generate with the current Better Auth CLI:
npx auth@latest generate \--config ./auth-generate.ts \--output ./schema-better-auth/schema.ts \--yes
--yes writes the output without an interactive confirmation. Review the generated diff when regenerating an existing module.
Import the generated tables in your app's schema.ts and merge them into the app definition:
schema.tsimport { schema as s } from "jazz-tools";import { migrations } from "./migrations";import { schema as betterauthSchema } from "./schema-better-auth/schema";const schema = {...betterauthSchema,messages: s.table({author_name: s.string(),chat_id: s.string(),text: s.string(),sent_at: s.timestamp(),},{},),// ...your own tables};type AppSchema = s.Schema<typeof schema>;export const app: s.App<AppSchema> = s.defineApp(schema, { migrations });
The generated schema-better-auth/schema.ts also ships a permissions export that denies all operations on the Better Auth tables. Merge it with your own permissions so regular client sessions can't read or write Better Auth rows — the adapter itself uses jazz.db, which has backend permissions.
permissions.tsimport { definePermissions } from "jazz-tools/permissions";import { permissions as betterAuthPermissions } from "./schema-better-auth/schema";import { app } from "./schema";const appPermissions = definePermissions(app, ({ policy }) => {// Define policies for your own tables.policy.messages.allowRead.always();});export default {...betterAuthPermissions,...appPermissions,};
After adding both files, validate the merged schema and permissions:
npx jazz-tools validate
Use Jazz as the database
Create Jazz in your backend (Main Backend), then pass jazz.db to jazzAdapter(...) as the database in your Better Auth config. Point both db and schema at the merged app, not at the generated module directly, so Better Auth and your own tables share a single Jazz app. authenticate asks the same Better Auth instance who a request's session cookie belongs to.
auth.tsimport { betterAuth } from "better-auth";import { createJazz, sqlite } from "jazz-tools/backend";import { jazzAdapter } from "jazz-tools/better-auth-adapter";import { app } from "./schema";import permissions from "./permissions";import { authOptions } from "./auth-options";export const jazz = createJazz({app,permissions,store: sqlite({ path: "./data/jazz" }), // or vercel() / remote(), as for any backendauthenticate: async (request) => {const session = await auth.api.getSession({ headers: request.headers });return session ? { userId: session.user.id } : null;},});export const auth = betterAuth({...authOptions,database: jazzAdapter({db: async () => jazz.db,schema: app.wasmSchema,}),});
| Option | Description |
|---|---|
db | A function returning Db or Promise<Db>, such as jazz.db. Use the merged app, not authSchema. |
schema | Typically app.wasmSchema from your merged schema. A raw s.App is also accepted. |
debugLogs | Better Auth adapter debug logging controls. |
usePlural | Whether Better Auth model names should use plural table names. |
prefix | Table name prefix (defaults to "better_auth_"). |
Compatibility
The adapter is currently aligned with Better Auth 1.7.1.
| Plugin/Feature | Compatibility |
|---|---|
| Email & Password auth | ✅ |
| Social Provider auth | ✅ |
| Email OTP | ✅ |