Skip to main content
Better Auth connects to a database to store users, sessions, accounts, and verification records. Plugins can also define their own tables to store additional data.
Better Auth can also run without any database. See Stateless Session Management for details.

Adapters

Pass a supported database instance to the database option in your auth config. Better Auth has built-in support for SQLite, PostgreSQL, MySQL, and MSSQL via a Kysely adapter, plus first-class ORM adapters for Drizzle and Prisma. Better Auth supports SQLite, PostgreSQL, MySQL, MSSQL, and more through the built-in Kysely adapter.

CLI

Better Auth ships a CLI tool to manage migrations and generate schema files.

Running migrations

The CLI checks your database and prompts you to add missing tables or update existing columns. This is only supported for the built-in Kysely adapter.
npx auth@latest migrate
For PostgreSQL users: the migrate command supports non-default schemas. It automatically detects your search_path configuration and creates tables in the correct schema.

Generating schema

For ORM adapters like Prisma or Drizzle, use the generate command to produce the correct schema definition for your ORM. For the built-in Kysely adapter, this generates an SQL file you can run directly.
npx auth@latest generate

Programmatic migrations

In environments where the CLI is unavailable (such as Cloudflare Workers), run migrations programmatically:
import { getMigrations } from "better-auth/db/migration";
import { auth } from "./auth";

const { toBeCreated, toBeAdded, runMigrations } = await getMigrations(auth.options);

await runMigrations();
getMigrations only works with the built-in Kysely adapter (SQLite/D1, PostgreSQL, MySQL, MSSQL). It does not work with Prisma or Drizzle ORM adapters — use CLI migrations with those ORMs instead.
Cloudflare D1 can only be queried through a Cloudflare Worker, so the CLI cannot access it directly. Run migrations through a protected endpoint instead:
auth.ts
import { env } from "cloudflare:workers";
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: env.DB,
  // ... rest of config
});
src/index.ts
import { Hono } from "hono";
import { auth } from "./auth";
import { getMigrations } from "better-auth/db/migration";

const app = new Hono();

// Protect or remove this endpoint in production
app.post("/migrate", async (c) => {
  try {
    const { toBeCreated, toBeAdded, runMigrations } = await getMigrations(auth.options);

    if (toBeCreated.length === 0 && toBeAdded.length === 0) {
      return c.json({ message: "No migrations needed" });
    }

    await runMigrations();
    return c.json({
      message: "Migrations completed successfully",
      created: toBeCreated.map((t) => t.table),
      added: toBeAdded.map((t) => t.table),
    });
  } catch (error) {
    return c.json(
      { error: error instanceof Error ? error.message : "Migration failed" },
      500,
    );
  }
});

app.on(["POST", "GET"], "/api/auth/*", (c) => {
  return auth.handler(c.req.raw);
});

export default app;

Secondary storage

Secondary storage lets you offload session data, verification records, and rate-limiting counters to a high-performance key-value store like Redis.

Interface

Implement the SecondaryStorage interface:
interface SecondaryStorage {
  get: (key: string) => Promise<unknown>;
  set: (key: string, value: string, ttl?: number) => Promise<void>;
  delete: (key: string) => Promise<void>;
}
Then pass your implementation to betterAuth:
auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  // ... other options
  secondaryStorage: {
    // your implementation
  },
});

Official Redis package

Better Auth provides an official Redis storage package backed by ioredis:
npm install @better-auth/redis-storage ioredis
auth.ts
import { betterAuth } from "better-auth";
import { Redis } from "ioredis";
import { redisStorage } from "@better-auth/redis-storage";

const redis = new Redis({
  host: "localhost",
  port: 6379,
});

export const auth = betterAuth({
  // ... other options
  secondaryStorage: redisStorage({
    client: redis,
    keyPrefix: "better-auth:", // optional, defaults to "better-auth:"
  }),
});
The Redis storage supports standalone, cluster, and sentinel configurations.

Manual Redis implementation

You can also implement secondary storage manually using the redis package:
auth.ts
import { createClient } from "redis";
import { betterAuth } from "better-auth";

const redis = createClient();
await redis.connect();

export const auth = betterAuth({
  // ... other options
  secondaryStorage: {
    get: async (key) => {
      return await redis.get(key);
    },
    set: async (key, value, ttl) => {
      if (ttl) await redis.set(key, value, { EX: ttl });
      else await redis.set(key, value);
    },
    delete: async (key) => {
      await redis.del(key);
    },
  },
});

Core schema

Better Auth requires four tables in your database. The types below use TypeScript notation — use the corresponding types for your database.

User table

FieldTypeDescription
idstring (PK)Unique identifier
namestringDisplay name
emailstring (unique)Email address
emailVerifiedbooleanWhether the email is verified
imagestring (optional)Avatar URL
createdAtDateAccount creation timestamp
updatedAtDateLast update timestamp

Session table

FieldTypeDescription
idstring (PK)Unique identifier
userIdstring (FK)Reference to the user
tokenstring (unique)Session token
expiresAtDateExpiry timestamp
ipAddressstring (optional)Client IP address
userAgentstring (optional)Client user agent
createdAtDateCreation timestamp
updatedAtDateLast update timestamp

Account table

FieldTypeDescription
idstring (PK)Unique identifier
userIdstring (FK)Reference to the user
accountIdstringProvider-assigned account ID
providerIdstringAuthentication provider ID
accessTokenstring (optional)OAuth access token
refreshTokenstring (optional)OAuth refresh token
accessTokenExpiresAtDate (optional)Access token expiry
refreshTokenExpiresAtDate (optional)Refresh token expiry
scopestring (optional)Granted OAuth scopes
idTokenstring (optional)OIDC ID token
passwordstring (optional)Hashed password (email/password auth)
createdAtDateCreation timestamp
updatedAtDateLast update timestamp

Verification table

FieldTypeDescription
idstring (PK)Unique identifier
identifierstringVerification request identifier
valuestringValue to verify
expiresAtDateExpiry timestamp
createdAtDateCreation timestamp
updatedAtDateLast update timestamp

Custom tables

Custom table and column names

Override table and column names using the modelName and fields properties:
auth.ts
export const auth = betterAuth({
  user: {
    modelName: "users",
    fields: {
      name: "full_name",
      email: "email_address",
    },
  },
  session: {
    modelName: "user_sessions",
    fields: {
      userId: "user_id",
    },
  },
});
Type inference in your code always uses the original field names (e.g., user.name, not user.full_name).
To customize plugin table names, use the schema property inside the plugin config:
auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";

export const auth = betterAuth({
  plugins: [
    twoFactor({
      schema: {
        user: {
          fields: {
            twoFactorEnabled: "two_factor_enabled",
            secret: "two_factor_secret",
          },
        },
      },
    }),
  ],
});

Extending the core schema

Add custom fields to the user or session tables using additionalFields. The CLI will automatically update the database schema, and these fields will be properly inferred by TypeScript in functions like useSession and signUp.email.
auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  user: {
    additionalFields: {
      role: {
        type: ["user", "admin"],
        required: false,
        defaultValue: "user",
        input: false, // prevent user from setting this at signup
      },
      lang: {
        type: "string",
        required: false,
        defaultValue: "en",
      },
    },
  },
});
Field options:
  • type: Data type — "string", "number", "boolean", "date", or a tuple of string literals for enums.
  • required: Whether the field is required on create.
  • defaultValue: Default value (JS layer only; the column is optional in the database).
  • input: When false, the field cannot be provided by the user at signup (useful for fields like role).
Accessing additional fields:
const res = await auth.api.signUpEmail({
  body: {
    email: "[email protected]",
    password: "password",
    name: "John Doe",
    lang: "fr",
  },
});

res.user.role; // "user"
res.user.lang; // "fr"

Mapping OAuth profiles to additional fields

Use mapProfileToUser to populate additional user fields from an OAuth provider’s profile:
auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  socialProviders: {
    github: {
      clientId: "YOUR_GITHUB_CLIENT_ID",
      clientSecret: "YOUR_GITHUB_CLIENT_SECRET",
      mapProfileToUser: (profile) => ({
        firstName: profile.name.split(" ")[0],
        lastName: profile.name.split(" ")[1],
      }),
    },
    google: {
      clientId: "YOUR_GOOGLE_CLIENT_ID",
      clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
      mapProfileToUser: (profile) => ({
        firstName: profile.given_name,
        lastName: profile.family_name,
      }),
    },
  },
});

ID generation

Better Auth generates unique string IDs for all entities by default. You can customize this behavior with advanced.database.generateId.

Let the database generate IDs

Set generateId to false to let your database handle all ID generation:
auth.ts
export const auth = betterAuth({
  database: db,
  advanced: {
    database: {
      generateId: false,
    },
  },
});

Custom ID generation function

Return false or undefined from the function to let the database generate IDs for specific models:
auth.ts
export const auth = betterAuth({
  database: db,
  advanced: {
    database: {
      generateId: (options) => {
        if (options.model === "user" || options.model === "users") {
          return false; // database generates IDs for users
        }
        return crypto.randomUUID();
      },
    },
  },
});

Auto-incrementing numeric IDs

Set generateId to "serial" for auto-incrementing numeric IDs across all tables. The CLI will generate the schema with the id field as a numeric type.
auth.ts
export const auth = betterAuth({
  database: db,
  advanced: {
    database: {
      generateId: "serial",
    },
  },
});
Better Auth infers id as a string internally but converts to and from the numeric type when reading or writing to the database. Always pass IDs as strings to Better Auth endpoints.

UUIDs

Set generateId to "uuid" to use UUID columns in your database. For PostgreSQL, Better Auth allows the database to generate the UUID automatically.

Database hooks

Database hooks execute custom logic before or after core database operations on the user, session, and account models.

Hook types

  • Before hook: Runs before create, update, or delete. Return false to abort the operation. Return a data object to replace the original payload.
  • After hook: Runs after create or update. Use for side effects like sending notifications or provisioning external resources.
auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          return {
            data: {
              ...user,
              firstName: user.name.split(" ")[0],
              lastName: user.name.split(" ")[1],
            },
          };
        },
        after: async (user) => {
          // e.g., create a Stripe customer
        },
      },
      delete: {
        before: async (user, ctx) => {
          if (user.email.includes("admin")) {
            return false; // abort deletion
          }
          return true;
        },
        after: async (user) => {
          console.log(`User ${user.email} has been deleted`);
        },
      },
    },
    session: {
      delete: {
        before: async (session, ctx) => {
          if (session.userId === "admin-user-id") {
            return false;
          }
          return true;
        },
      },
    },
  },
});

Throwing errors from hooks

Use APIError to abort an operation and return an error response:
auth.ts
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";

export const auth = betterAuth({
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          if (user.isAgreedToTerms === false) {
            throw new APIError("BAD_REQUEST", {
              message: "User must agree to the TOS before signing up.",
            });
          }
          return { data: user };
        },
      },
    },
  },
});

Using the context object

The ctx object (second argument) carries useful information. For update hooks, it includes the current session:
auth.ts
export const auth = betterAuth({
  databaseHooks: {
    user: {
      update: {
        before: async (data, ctx) => {
          if (ctx.context.session) {
            console.log("Update initiated by:", ctx.context.session.userId);
          }
          return { data };
        },
      },
    },
  },
});

Plugin schema

Plugins can define their own tables and add columns to core tables. For example, the two-factor authentication plugin adds twoFactorEnabled, twoFactorSecret, and twoFactorBackupCodes columns to the user table. To update your schema after adding a plugin, run npx auth@latest migrate or npx auth@latest generate.

Experimental joins

Since Better Auth v1.4, you can enable experimental database joins to reduce the number of database roundtrips. Over 50 endpoints support joins.
auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  experimental: { joins: true },
});
After enabling joins, run migrate or generate to update your ORM schema with the required relationship definitions.
Read the adapter-specific joins documentation before enabling: Drizzle · Prisma · SQLite · PostgreSQL

Build docs developers (and LLMs) love