Hooks

Hooks let you adjust Better Auth endpoint behavior without replacing the endpoint.

Before Hooks

Before hooks run before the endpoint handler.

Example: Enforce Email Domain Restriction

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  hooks: {
    before: [
      {
        matcher: ->(ctx) { ctx.path == "/sign-up/email" },
        handler: lambda do |ctx|
          email = ctx.body["email"].to_s
          if email.end_with?("@blocked.test")
            BetterAuth::APIError.new("BAD_REQUEST", message: "Email domain is blocked")
          end
        end
      }
    ]
  }
)

Return an APIError, Rack response, endpoint result, or hash response to stop the request early.

Example: Modify Request Context

hooks: {
  before: [
    {
      matcher: ->(ctx) { ctx.path == "/sign-up/email" },
      handler: lambda do |ctx|
        ctx.merge_context!(
          body: {
            "name" => ctx.body["name"].to_s.strip
          }
        )
        nil
      end
    }
  ]
}

After Hooks

After hooks run after the endpoint handler and can replace or decorate the response.

Example: Send A Notification When A New User Is Registered

hooks: {
  after: [
    {
      matcher: ->(ctx) { ctx.path == "/sign-up/email" },
      handler: lambda do |ctx|
        ctx.context.run_in_background(
          -> { Analytics.track("signup", email: ctx.body["email"]) }
        )
        nil
      end
    }
  ]
}

Ctx

Hook handlers receive BetterAuth::Endpoint::Context.

Request Response

JSON Responses

handler: ->(ctx) { ctx.json({blocked: true}, status: 403) }

Redirects

handler: ->(ctx) { ctx.redirect("/login") }

Cookies

handler: lambda do |ctx|
  ctx.set_cookie("theme", "dark", same_site: "lax", path: "/")
  nil
end

Read request cookies with:

ctx.get_cookie("better-auth.session_token")

Errors

handler: ->(ctx) { ctx.error("UNAUTHORIZED", message: "login required") }

Context

ctx.context is the auth context. It exposes app options, cookies, adapters, configured social providers, secrets, logger, session config, and rate limit config.

New Session

ctx.context.new_session contains a newly created session when a route creates one.

Returned

Endpoint results are normalized into Rack responses after hooks run.

Response Headers

Use ctx.set_header to add headers:

ctx.set_header("x-auth-flow", "signup")

Predefined Auth Cookies

Auth cookie definitions are available through:

ctx.context.auth_cookies

Secret

ctx.context.secret

Password

password = ctx.context.password
hash = password[:hash].call("password123")
password[:verify].call(password: "password123", hash: hash)

Adapter

ctx.context.adapter

Internal Adapter

Use the internal adapter to preserve database hooks and secondary storage semantics.

ctx.context.internal_adapter.find_user_by_email("john@example.com")

GenerateId

The configured ID generator lives at:

ctx.context.options.advanced.dig(:database, :generate_id)

RunInBackground

ctx.context.run_in_background(
  -> { AuditLog.create!(event: "auth_event") }
)

Configure the background task handler:

advanced: {
  background_tasks: {
    handler: ->(task) { Thread.new { task.call } }
  }
}

RunInBackgroundOrAwait

Ruby exposes one background entry point: run_in_background. If no handler is configured, the task is called inline.

Reusable Hooks

Extract hook definitions into methods or constants.

RequireCompanyEmail = {
  matcher: ->(ctx) { ctx.path == "/sign-up/email" },
  handler: lambda do |ctx|
    next if ctx.body["email"].to_s.end_with?("@company.com")

    ctx.error("BAD_REQUEST", message: "Use your company email")
  end
}

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  hooks: {
    before: [RequireCompanyEmail]
  }
)