Database

Better Auth Ruby stores auth state through an adapter. The core gem is Rack-framework independent, and the Rails adapter can use Active Record.

Adapters

Built-in core adapters include:

  • :memory for tests and local examples.
  • SQLite.
  • PostgreSQL.
  • MySQL.
  • MSSQL.
  • MongoDB shim support.
  • Active Record through better_auth-rails.
auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  database: :memory
)
Rails initializer
Rails.application.config.better_auth = BetterAuth.auth(
  secret: Rails.application.credentials.fetch(:better_auth_secret),
  database: :active_record
)

CLI

Running Migrations

Rails apps use Rails migrations:

bin/rails generate better_auth:migration
bin/rails db:migrate

Generating Schema

Generate SQL programmatically for Rack apps:

sql = BetterAuth::Schema::SQL.generate(auth.options, adapter: :postgres)
File.write("db/auth_schema.sql", sql)

Programmatic Migrations

BetterAuth::Schema.auth_tables(auth.options) returns the logical schema after custom table names, custom field names, additional fields, plugins, and rate-limit storage are merged.

tables = BetterAuth::Schema.auth_tables(auth.options)
tables.fetch("user").fetch(:fields).keys

Use this when integrating with your own migration system.

Secondary Storage

Secondary storage lets Better Auth keep sessions, verification tokens, or rate-limit counters outside the main database.

Implementation

Provide an object with get, set, and delete.

class RedisStorage
  def initialize(redis)
    @redis = redis
  end

  def get(key)
    @redis.get(key)
  end

  def set(key, value, ttl = nil)
    ttl ? @redis.set(key, value, ex: ttl) : @redis.set(key, value)
  end

  def delete(key)
    @redis.del(key)
  end
end

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  secondary_storage: RedisStorage.new(redis)
)

Redis Storage

Use a Redis-backed storage object when running multiple app processes or serverless instances.

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  secondary_storage: RedisStorage.new(Redis.new(url: ENV.fetch("REDIS_URL"))),
  rate_limit: {
    enabled: true,
    storage: "secondary-storage"
  }
)

Core Schema

User

The user table stores profile identity and verification state.

FieldPurpose
idUser ID.
nameDisplay name.
emailNormalized email.
emailVerifiedWhether the email has been verified.
imageOptional profile image URL.
createdAtCreation timestamp.
updatedAtUpdate timestamp.

Session

The session table stores active session records when sessions are stored in the database.

FieldPurpose
idSession ID.
expiresAtExpiration timestamp.
tokenSession token.
ipAddressClient IP address when available.
userAgentUser agent when available.
userIdOwning user ID.
createdAtCreation timestamp.
updatedAtUpdate timestamp.

Account

The account table links users to credentials and OAuth providers.

FieldPurpose
idAccount ID.
accountIdProvider account ID.
providerIdProvider ID, such as credential or github.
userIdOwning user ID.
accessTokenOAuth access token, optionally encrypted.
refreshTokenOAuth refresh token, optionally encrypted.
idTokenOAuth ID token.
scopeStored provider scopes.
passwordCredential password hash.

Verification

The verification table stores temporary tokens for email verification, password reset, account deletion, and plugin flows.

FieldPurpose
idVerification ID.
identifierLookup identifier.
valueToken value or payload.
expiresAtExpiration timestamp.
createdAtCreation timestamp.
updatedAtUpdate timestamp.

Custom Tables

Custom Table Names

Override physical table names with model_name.

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  user: {
    model_name: "users"
  },
  session: {
    model_name: "auth_sessions"
  }
)

Extending Core Schema

Use additional_fields to add fields to core models.

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  user: {
    additional_fields: {
      role: {
        type: "string",
        required: false,
        input: false
      }
    }
  },
  session: {
    additional_fields: {
      activeOrganizationId: {
        type: "string",
        required: false
      }
    }
  }
)

Fields with input: false are not accepted from public endpoint bodies.

ID Generation

Better Auth Ruby generates string IDs by default.

Option 1: Let Database Generate IDs

For adapters or migrations where the database owns IDs, configure the schema and adapter accordingly in your app.

Option 2: Custom ID Generation Function

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  advanced: {
    database: {
      generate_id: -> { SecureRandom.uuid }
    }
  }
)

Option 3: Consistent Custom ID Generator

Share the same generator across the app when multiple models need consistent ID shape.

id_generator = -> { "auth_#{SecureRandom.hex(12)}" }

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  advanced: {
    database: {
      generate_id: id_generator
    }
  }
)

Numeric IDs

String IDs are the default. UUIDs are available with:

If your app uses numeric IDs, keep the auth schema and adapter expectations aligned before migrating existing data.

UUIDs

UUIDs are available with:

advanced: {
  database: {
    generate_id: "uuid"
  }
}

Mixed ID Types

Avoid mixing ID types inside the same auth schema. If an existing application already has mixed IDs, keep conversions at the adapter boundary and cover them with adapter tests.

Database Hooks

Database hooks run around adapter create, update, and delete operations.

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  database_hooks: {
    user: {
      create: {
        before: lambda do |user, ctx|
          {data: user.merge("name" => user["name"].to_s.strip)}
        end,
        after: lambda do |user, ctx|
          Rails.logger.info("Created auth user #{user["id"]}")
        end
      }
    }
  }
)

1. Before Hook

Return {data: hash} to merge data into the operation.

before: ->(user, _ctx) { {data: user.merge("image" => nil)} }

Return false to abort create, update, or delete.

before: ->(_user, _ctx) { false }

2. After Hook

After hooks receive the stored record.

after: ->(user, _ctx) { AuditLog.create!(subject_id: user["id"]) }

Throwing Errors

Raise BetterAuth::APIError or another exception from a hook when the request should fail.

before: lambda do |user, _ctx|
  raise BetterAuth::APIError.new("BAD_REQUEST", message: "domain blocked") if user["email"].end_with?("@blocked.test")
  {data: user}
end

Using The Context Object

Routes that pass endpoint context into adapter calls make it available as the second hook argument.

before: lambda do |user, ctx|
  request_ip = ctx&.request&.ip
  {data: user.merge("signupIp" => request_ip)}
end

Plugins Schema

Plugins can add fields to core tables or add new tables.

audit_plugin = BetterAuth::Plugin.new(
  id: "audit",
  schema: {
    auditEvent: {
      fields: {
        userId: {type: "string", required: true},
        action: {type: "string", required: true}
      }
    },
    session: {
      fields: {
        activePluginId: {type: "string", required: false}
      }
    }
  }
)

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  plugins: [audit_plugin]
)

Experimental Joins

Set experimental: {joins: true} when using an adapter that can return joined user data for session and account lookups.

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  experimental: {
    joins: true
  }
)

Use this only after verifying your adapter's join behavior in your environment.