Integrate Better Auth
Share a Drizzle database with Better Auth, define and migrate authentication tables, mount its API, and keep auth data boundaries explicit.
Store authentication tables alongside your application’s tables using its existing Drizzle database service. Keep database connectivity and table definitions in the database domain, authentication configuration in the auth domain, and business operations in their owning services. Do not open a second connection or build a second database service just for authentication.
This guide covers the database side of integration and the minimum route/session wiring needed to verify it. Continue with Auth getting started for the architecture, Better Auth setup for service configuration, and Clients for sign-up, sign-in, and sign-out.
Install before extending the schema
Before installing, merge these selected declaration repairs into the existing package manifest, preserving unrelated overrides:
{
"overrides": {
"@better-fetch/fetch": "1.3.2"
},
"pnpm": {
"overrides": {
"@better-fetch/fetch": "1.3.2"
}
}
}
From this guide’s application/package root, install only its selected requirements:
bun add @relkit/better-auth@0.8.2 @relkit/drizzle@0.8.1 better-auth@1.7.1 drizzle-orm@1.0.0-rc.5-169397b
bun add --dev @cloudflare/workers-types@4.20250121.0 create-relkit@0.8.4 drizzle-kit@1.0.0-rc.5-ab785fc
The selected Drizzle version also needs the release’s declaration repair. The development-only create-relkit package supplies its exact asset; a dependency cannot register Bun patches for its consumer.
mkdir -p patches
cp node_modules/create-relkit/dist/patches/drizzle-orm.patch patches/drizzle-orm@1.0.0-rc.5-169397b.patch
Merge this entry into the application’s existing patchedDependencies, preserving other patches. Keep the asset in source control:
{
"patchedDependencies": {
"drizzle-orm@1.0.0-rc.5-169397b": "patches/drizzle-orm@1.0.0-rc.5-169397b.patch"
}
}
Then apply the registered repair before check/typecheck:
bun install
Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in dependencies; build/type tooling belongs in devDependencies. Installing an adapter does not supply its optional SDK peers or enable the feature’s configuration.
Understand the shared connection
src/services/database/service.ts → activated native Drizzle client
├─ RELKIT table clients → business functions via context.database
└─ Better Auth Drizzle adapter → auth handler and session lookup
src/routes/api/auth/[[...auth]]/route.ts → auth.handler at /api/auth
RELKIT activates Drizzle before Better Auth and supplies the native client to Better Auth’s Drizzle
adapter. The adapter uses the database service’s schema by default and derives its provider from the
service dialect. You do not call drizzleAdapter() yourself in a RELKIT auth service.
The current integration requires one Drizzle service, one Better Auth service, and one filesystem
ALL route mounting the auth handler. It does not support selecting a second database for auth.
Add the authentication tables
The executable examples/auth-drizzle application uses Better Auth 1.7.1 and the repository’s pinned
Drizzle version. Its table definitions are:
/** Declares native Better Auth SQLite user/session/account/verification tables, retaining timestamp modes and account uniqueness. */
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
const timestamps = {
createdAt: integer({ mode: "timestamp" }).notNull(),
updatedAt: integer({ mode: "timestamp" }).notNull(),
};
export const user = sqliteTable("user", {
id: text().primaryKey(),
name: text().notNull(),
email: text().notNull().unique(),
emailVerified: integer({ mode: "boolean" }).notNull(),
image: text(),
...timestamps,
});
export const session = sqliteTable("session", {
id: text().primaryKey(),
expiresAt: integer({ mode: "timestamp" }).notNull(),
token: text().notNull().unique(),
ipAddress: text(),
userAgent: text(),
userId: text().notNull(),
...timestamps,
});
export const account = sqliteTable(
"account",
{
id: text().primaryKey(),
accountId: text().notNull(),
issuer: text().notNull(),
providerId: text().notNull(),
userId: text().notNull(),
accessToken: text(),
refreshToken: text(),
idToken: text(),
accessTokenExpiresAt: integer({ mode: "timestamp" }),
refreshTokenExpiresAt: integer({ mode: "timestamp" }),
scope: text(),
password: text(),
...timestamps,
},
(table) => [uniqueIndex("account_issuer_account_id").on(table.issuer, table.accountId)],
);
export const verification = sqliteTable("verification", {
id: text().primaryKey(),
identifier: text().notNull(),
value: text().notNull(),
expiresAt: integer({ mode: "timestamp" }).notNull(),
...timestamps,
});
The singular export keys user, session, account, and verification match the adapter’s default
model names. Preserve required fields, nullability, key types, and timestamp/boolean mappings for your
installed Better Auth version. This version’s account model includes issuer and the compound
issuer/accountId identity; do not substitute an older four-table example without comparing it.
Add these table exports to the schema map already passed to defineDrizzleService. If you split them
into src/services/database/schema/auth.ts, re-export them from the schema entry point used by both the service
and Drizzle Kit. Do not replace existing business table exports or nest the auth tables under an auth
property: the service needs direct table exports.
The Users tutorial’s users table is not Better Auth’s user model. Its numeric ID and fields are
different. Decide whether you are adding separate domain profiles referencing auth user IDs or migrating
an existing identity model. Do not silently repurpose existing user records or their foreign keys.
Generate and apply the schema changes
Use the same Drizzle Kit workflow as the rest of the database. With the configuration from that guide and an explicitly selected database, run from your app root:
export DATABASE_PATH=./data-model.sqlite
bun --bun drizzle-kit generate --name=better-auth
Review and test the generated change, then run bun run relkit db migrate before enabling the auth
service. A table export registers a model; it does not create or update the physical table. Adding
Better Auth does not cause RELKIT to generate or apply migrations automatically.
Keep auth and business-table SQL/metadata together in the database service’s tracked migration history.
Auth plugins and additional fields can require further tables, columns, and indexes. Keep the Better
Auth configuration, Drizzle definitions, and migrations aligned. If you use Better Auth’s own schema
generator, its input must be a native Better Auth configuration supported by that tool: a RELKIT
service descriptor is not the activated betterAuth() instance. Do not point an unrelated CLI at the
descriptor and assume it can discover this integration.
Define the auth service
After the installation and migrations above, the local showcase enables email/password authentication:
Backend activation uses @relkit/better-auth/drizzle; React is optional and belongs only to the
/react entrypoint. Native Drizzle drivers stay selected by the application’s database setup.
/** Configures email/password Better Auth against the sole database, borrowing URL and secret from server configuration. */
import { defineBetterAuthService } from "@relkit/better-auth";
export default defineBetterAuthService({
baseURL: process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:3000",
// Better Auth reads BETTER_AUTH_SECRET from the server process environment.
emailAndPassword: { enabled: true },
});
The service uses Better Auth’s native BETTER_AUTH_SECRET process-environment lookup and has no
hard-coded secret. Supply a strong, stable server secret before startup; configure BETTER_AUTH_URL
and trusted origins for the real application. The loopback URL is a local default only. The integration
does not accept an ({ env }) factory for auth options, and merely declaring a field in defineEnv
does not inject it into native options or populate the process environment. See
Auth environment setup.
Do not set database or basePath on defineBetterAuthService: both are reserved. RELKIT supplies the
database and derives the base path from the mounting route. Optional drizzle adapter settings can
customize behavior such as schema mapping; provider remains derived from the database dialect.
If you use custom model/field names, align Better Auth’s naming configuration with the adapter’s schema
mapping. An optional drizzle.schema map changes what the adapter sees, not what the database service
registers or what Kit migrates. Keep those definitions and table identities consistent.
Mount the API and read the session
Create the filesystem catch-all route:
/** Mounts native Better Auth once for all methods and protects the declared account, order and MCP paths. */
import { defineRoute } from "@relkit/app/routes";
import auth from "@app/services/auth/service.js";
export const ALL = defineRoute({
handler: auth.handler,
auth: { protected: ["/account/*", "/orders/*", "/mcp"] },
});
This mounts the API at /api/auth and protects the listed application paths. Authentication endpoints
must remain reachable so a caller can sign in. Use the registered raw handler rather than wrapping it
in a JSON business function; auth owns its response headers and cookies. See
Raw HTTP handlers for mounting rules.
See Protect routes for path matching, public auth endpoints, and permission checks.
A business function can read the request’s session:
/** Reads the invocation's native session and returns validated authentication and user identity fields. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
export default defineFunction({
input: z.object({}),
output: z.object({ authenticated: z.boolean(), userId: z.string().optional() }),
handler: async (_input, context) => {
const session = await context.auth.getSession();
return session === null
? { authenticated: false }
: { authenticated: true, userId: session.user.id };
},
});
Matching a protected route requires authentication, not permission to every row. Enforce ownership, organization membership, and roles in the business operation. Work without an HTTP request, such as a job, must not assume there is a caller cookie session.
Keep auth writes inside the auth system
Better Auth talks to the native Drizzle client. Its writes do not pass through RELKIT’s portable CRUD
methods, generated Zod parsers, or operation overrides. A users.findOne override cannot enforce
Better Auth’s account policy or session behavior.
Conversely, generated CRUD access to auth tables does not make it safe to expose them. Let Better Auth
own password hashing, account linking, token/session lifecycle, and authentication-specific updates.
Do not publish generic read/write routes for account, session, or verification records; their rows
can contain credentials and tokens. A context.database.transaction callback also does not automatically
enlist a separate Better Auth API call in its transaction.
Verify before adding clients
From the repository’s examples/auth-drizzle directory:
bun run check
bun run typecheck
bun test tests/auth.test.ts
The showcase test migrates a temporary database and checks public/protected requests, sign-up, session lookup, sign-out and old-cookie rejection, invalid credentials, and sign-in. Follow Auth testing for the executable test and environment setup. It is a local wiring/migration check, not proof that your custom fields/plugins, browser cookies, production dialect, or deployment policy are correct.
For setup failures, first check that the auth tables are in the service’s schema map, the migration
and app use the same database, and the catch-all route mounts the registered auth.handler exactly once.