---
title: Integrate Better Auth
description: Share a Drizzle database with Better Auth, define and migrate authentication tables, mount its API, and keep auth data boundaries explicit.
pagination: false
sidebar:
  icon: shield-check
---

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](/docs/modules/auth) for the architecture,
[Better Auth setup](/docs/modules/auth/better-auth) for service configuration, and
[Clients](/docs/modules/auth/clients) for sign-up, sign-in, and sign-out.

## Install before extending the schema

> **Before you begin**
>
> Complete [database setup](/docs/modules/database/define) first. The auth scaffold installs its
> selected requirements automatically; for the manual path, merge the following into that same
> backend. Keep the existing database service, table exports and applied migration history.

{/* Generated by apps/docs/scripts/generate.ts. Do not edit. */}

Before installing, merge these selected declaration repairs into the existing package manifest, preserving unrelated overrides:

```json
{
  "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:

```sh
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.

```sh
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:

```json
{
  "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:

```sh
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

```text
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:

```ts title="src/services/database/schema/index.ts"
/** 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](/docs/modules/database/migrations) as the rest of the database. With the
configuration from that guide and an explicitly selected database, run from your app root:

```sh
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.

```ts title="src/services/auth/service.ts — local demonstration"
/** 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](/docs/modules/auth/better-auth#define-the-service-and-environment).

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:

```ts title="src/routes/api/auth/[[...auth]]/route.ts"
/** 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](/docs/modules/http/raw-handlers) for mounting rules.
See [Protect routes](/docs/modules/auth/protect-routes) for path matching, public auth endpoints, and permission checks.

A business function can read the request's session:

```ts title="src/services/account/functions/session.function.ts"
/** 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:

```sh
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](/docs/modules/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.

{/* Generated by apps/docs/scripts/generate.ts. Do not edit. */}

**Reference and source**

- [`@relkit/drizzle` API](/docs/api/drizzle)
- [`@relkit/routes` API](/docs/api/routes)

- [`examples/auth-drizzle/src/services/auth/service.ts`](https://github.com/rel-kit/relkit/blob/main/examples/auth-drizzle/src/services/auth/service.ts)
- [`examples/auth-drizzle/src/services/database/schema/index.ts`](https://github.com/rel-kit/relkit/blob/main/examples/auth-drizzle/src/services/database/schema/index.ts)
