better-effect
Integrations and adapters

Better Auth

Adapt a Better Auth server to typed better-effect Programs.

better-effect-better-auth is a small, server-side adapter. Better Auth still owns authentication, cookies, sessions, plugins, database adapters, and its Web-standard handler. The adapter turns a lazy or prebuilt Better Auth instance into a yieldable better-effect Service whose operations use better-result.

Install

Install the package from its package-qualified npm release together with the Better Auth and better-effect peers:

bun add better-effect-better-auth better-auth better-effect better-result
# Add Hono only when importing the optional /hono subpath:
bun add hono

The package's framework-neutral . entry point has these peer requirements:

  • better-auth ^1.7.0
  • better-effect >=0.12.0 <0.14.0
  • better-result ^3.0.0
  • TypeScript >=6.0.0

The package also exposes optional public /hooks and /hono subpaths. Hono is an optional peer required only when importing /hono (>=4.0.0); it is not needed by . or /hooks.

The adapter has no runtime framework or database dependency. Configure Better Auth normally, then adapt the instance at the application composition root:

import { betterAuth } from 'better-auth'
import { memoryAdapter } from 'better-auth/adapters/memory'
import { Effect, Layer, Runtime, Service } from 'better-effect'
import { BetterAuth } from 'better-effect-better-auth'
import { Result } from 'better-result'

class AppConfig extends Service<AppConfig>()('@app/AppConfig') {
  readonly baseURL = 'http://localhost:3000'
}

const Auth = BetterAuth.make('@app/Auth', async function* () {
  const config = yield* AppConfig

  return betterAuth({
    baseURL: config.baseURL,
    basePath: '/api/auth',
    database: memoryAdapter({
      account: [],
      session: [],
      user: [],
      verification: []
    }),
    emailAndPassword: { enabled: true },
    secret: 'replace-this-example-secret'
  })
})

const readSession = (request: Request) =>
  Effect.fn(async function* () {
    const auth = yield* Auth
    const session = yield* auth.session.get(request)

    return Result.ok(session)
  })

const runtime = await Runtime.make(
  Layer.merge(Layer.succeed(AppConfig, new AppConfig()), Auth.layer)
)
const result = await runtime.run(readSession(new Request('http://localhost:3000')))
await runtime.dispose()

Auth.layer provides the adapted Service. auth.raw is the original Better Auth instance when an application needs an intentionally unadapted Better Auth API. The Service tag must be unique when several Better Auth instances are provided to one Runtime.

When the application already owns a configured instance, use BetterAuth.from('@app/Auth', rawAuth). It provides the same facade through Layer.succeed and never closes or reconfigures the borrowed instance.

Endpoints and transport modes

Every endpoint visible on the concrete Better Auth instance is available under auth.api with the same inferred input and output types:

const program = Effect.fn(async function* () {
  const auth = yield* Auth
  const data = yield* auth.api.getSession({
    headers: request.headers
  })
  const response = yield* auth.api.getSession.asResponse({
    headers: request.headers
  })
  const withHeaders = yield* auth.api.signInEmail.withHeaders({
    body: {
      email: 'ada@example.com',
      password: 'a-password'
    }
  })

  return Result.ok({ data, response, withHeaders })
})

The normal mode returns the endpoint's data, .asResponse returns the Web Response, and .withHeaders returns { response, headers }. Transport choices are methods rather than Better Auth's asResponse, returnHeaders, or returnStatus input flags, so endpoint input remains the schema-defined input. The wrapper preserves response bodies, status codes, redirect locations, and headers, including repeated set-cookie headers.

Sessions

auth.session.get(requestOrHeaders) returns the inferred Better Auth session or null. auth.session.require(requestOrHeaders) returns the same session type but fails with Unauthenticated when no session is present. Neither helper creates a session or changes Better Auth's cookie behavior.

const readSessions = (request: Request) =>
  Effect.fn(async function* () {
    const auth = yield* Auth
    const optional = yield* auth.session.get(request)
    const required = yield* auth.session.require(request)

    return Result.ok({ optional, required })
  })

const sessionRuntime = await Runtime.make(Auth.layer)
const sessionResult = await sessionRuntime.run(
  readSessions(new Request('http://localhost:3000/api/auth/get-session'))
)
await sessionRuntime.dispose()

Plugin-added user and session fields are preserved when the Service is created from a concrete betterAuth(...) value. Plugin endpoints and $ERROR_CODES are likewise available to TypeScript:

import { admin } from 'better-auth/plugins'

const Auth = BetterAuth.make('@app/Auth', async function* () {
  return betterAuth({
    // ...database and normal Better Auth options
    plugins: [admin()]
  })
})

const program = Effect.fn(async function* () {
  const auth = yield* Auth
  const users = yield* auth.api.listUsers({ query: { limit: 10 } })

  return Result.ok(users)
})

Keep plugin definitions and configuration in the Better Auth application. Do not add plugin-specific behavior to the adapter.

Hono request-scoped sessions

The optional public better-effect-better-auth/hono subpath requires Hono. BetterAuthHono.session creates a typed current-session Service linked to one exact Better Auth Service:

import { Hono } from 'hono'
import { Effect, Layer, Runtime } from 'better-effect'
import { HonoEffect } from 'better-effect/hono'
import { BetterAuthHono } from 'better-effect-better-auth/hono'
import { Result } from 'better-result'

const CurrentSession = BetterAuthHono.session('@app/CurrentSession', Auth, {
  disableCookieCache: true
})
const AppLive = Layer.empty
const App = HonoEffect.app(
  '@app/HonoApp',
  {
    requestLayer: CurrentSession.requestLayer,
    onFailure: (_error, context) => context.json({ error: 'Request failed' }, 500)
  },
  async function* (http) {
    const auth = yield* Auth
    const app = new Hono()

    // Match basePath and register Better Auth before conflicting catch-all middleware.
    app.all('/api/auth/*', (context) => auth.raw.handler(context.req.raw))
    app.use('*', yield* http.middleware())
    app.use('/private/*', yield* http.guard(CurrentSession.guard))
    app.get(
      '/private/me',
      yield* http.gen(async function* () {
        const session = yield* CurrentSession.require()
        return Result.ok({ userId: session.user.id })
      })
    )

    return app
  }
)
const runtime = await Runtime.make(Layer.merge(AppLive, Auth.layer, App.layer))
const appResult = await runtime.run(
  Effect.fn(async function* () {
    return Result.ok(yield* App)
  })
)
if (Result.isError(appResult)) throw new Error(String(appResult.error))
const app = appResult.value

The request Layer requires Auth from the Runtime and provides only the request-scoped CurrentSession Service. The first get() or require() is lazy. All reads in one request share one settled lookup, including a missing session or failure, so guards and handlers do not trigger an implicit retry or refresh. get() returns the plugin-inferred session or null; require() maps only null to Unauthenticated. Better Auth API failures remain BetterAuthApiError, while unexpected throws and rejections become a new UnhandledException with the original defect only in .cause, never as the failure itself. A sign-in or sign-out performed after the snapshot's first read does not update that snapshot. For a consciously fresh read, use the Auth Service with the original request:

const readFreshSession = (request: Request) =>
  Effect.fn(async function* () {
    const auth = yield* Auth
    const fresh = yield* auth.session.get(request)
    return Result.ok(fresh)
  })

HonoEffect's onFailure callback decides the HTTP response. Keep Better Auth's configured basePath: '/api/auth' aligned with the raw route and register that route before conflicting catch-alls.

Handler integration

auth.handle(request) adapts Better Auth's Web-standard handler to an Effect operation and preserves its Response exactly. It is framework-neutral, so a Hono application can continue to use Better Auth's documented route directly:

import { Hono } from 'hono'

const app = new Hono()
app.all('/api/auth/*', (context) => rawAuth.handler(context.req.raw))

Use auth.handle when the handler itself belongs inside a better-effect Program. The optional public better-effect-better-auth/hono subpath adds only the request-scoped session helper; it does not replace or wrap Better Auth's Web handler, and Hono remains an application-owned optional peer.

Hooks and plugin middleware

The optional public better-effect-better-auth/hooks subpath adapts Better Auth's public createAuthMiddleware contract with a Layer-first declaration. Define the hook token before the raw Auth instance; yield its builders while BetterAuth.make(...).layer is acquired. The application still owns one Runtime root:

import { betterAuth } from 'better-auth'
import { Effect, Layer, Runtime, Service } from 'better-effect'
import { BetterAuth } from 'better-effect-better-auth'
import { BetterAuthHooks } from 'better-effect-better-auth/hooks'
import { Result } from 'better-result'

// Database is an application Service and this live value is application-owned.
const database = createApplicationDatabase()
const coreValues = Layer.succeed(Database, database)
const AuthHooks = BetterAuthHooks.define('@app/BetterAuthHookContext')

class RequestMetadata extends Service<RequestMetadata>()('@app/RequestMetadata') {
  readonly path!: string
}

const Auth = BetterAuth.make('@app/Auth', async function* () {
  const requestAware = yield* AuthHooks.middleware(
    () =>
      Effect.fn(async function* () {
        const metadata = yield* RequestMetadata
        return Result.ok({ context: { path: metadata.path } })
      }),
    {
      layer: (context) => Layer.succeed(RequestMetadata, RequestMetadata.of({ path: context.path }))
    }
  )

  return betterAuth({ hooks: { before: requestAware } })
})

const runtime = await Runtime.make(Layer.merge(coreValues, Auth.layer))

coreValues is a value Layer owned by the application, and Auth.layer is acquired into the same Runtime root. The request Layer is merged with the execution-scoped AuthHooks.Context Layer for each invocation, so its Services are checked as requirements of the Auth Layer and scoped resources are released when the execution closes. Better Auth still owns hook matching, plugin configuration, background work, authentication, cookies, sessions, adapters, and its Web-standard handler.

Stop accepting requests before shutdown, then dispose the application Runtime and close caller-owned shared resources last:

await runtime.dispose()
await database.close()

Layer.succeed does not transfer disposal of a value to a Runtime. Hook Programs should depend on core services rather than the Better Auth Service itself, avoiding an Auth hook → Runtime → Auth cycle.

A hook callback receives the original Better Auth middleware context. The same reference is available to deeper Programs through AuthHooks.Context:

import { betterAuth } from 'better-auth'
import type { BetterAuthPlugin } from 'better-auth'
import { CurrentAbortSignal } from 'better-effect'
import { BetterAuth } from 'better-effect-better-auth'

const AuthWithObserver = BetterAuth.make('@app/AuthWithObserver', async function* () {
  const observe = yield* AuthHooks.middleware((context) =>
    Effect.fn(async function* () {
      const hook = yield* AuthHooks.Context
      const executionSignal = yield* CurrentAbortSignal

      // Both contexts refer to the same Better Auth invocation.
      void (context === hook.context)
      // The original request signal remains available without replacement.
      void (hook.context.request?.signal === context.request?.signal)
      // CurrentAbortSignal may be linked to Runtime shutdown as well.
      void executionSignal

      return Result.ok()
    })
  )

  const auditPlugin = {
    id: 'audit-plugin',
    hooks: {
      after: [{ matcher: (context) => context.path === '/sign-in/email', handler: observe }]
    },
    middlewares: [{ path: '/audit/*', middleware: observe }]
  } satisfies BetterAuthPlugin

  return betterAuth({ plugins: [auditPlugin] })
})

Result.ok(...) values cross the bridge unchanged. A Response or context replacement is passed back to Better Auth; typed failures require an explicit onFailure mapper to an APIError or Response.

Better Auth's public rawAuth.api.* dispatch runs configured global and plugin hooks, including for requestless server-side calls. Plugin middlewares are request-only and run when a request goes through rawAuth.handler(...); they do not run for direct API dispatch.

Failures and boundaries

Expected Better Auth APIError values become BetterAuthApiError<BetterAuthErrorCode<typeof rawAuth>>. The adapter keeps the status, status code, runtime code, body, headers, and original cause in memory; its serialized form redacts those potentially sensitive details. Unexpected throws and rejected non-API errors become better-result's UnhandledException. Unauthenticated is reserved for session.require.

For explicit application handling, match BetterAuthApiError by _tag, code, and statusCode, then translate it to a domain failure at your own boundary:

import { BetterAuthApiError, Unauthenticated } from 'better-effect-better-auth'

type DomainFailure =
  | { readonly _tag: 'LoginRequired' }
  | { readonly _tag: 'InvalidCredentials' }
  | {
      readonly _tag: 'AuthProviderFailure'
      readonly code: string | undefined
      readonly statusCode: number
    }

const toDomainFailure = (failure: BetterAuthApiError | Unauthenticated): DomainFailure => {
  if (failure._tag === 'Unauthenticated') {
    return { _tag: 'LoginRequired' }
  }

  if (
    failure._tag === 'BetterAuthApiError' &&
    failure.code === 'INVALID_EMAIL_OR_PASSWORD' &&
    failure.statusCode === 401
  ) {
    return { _tag: 'InvalidCredentials' }
  }

  return {
    _tag: 'AuthProviderFailure',
    code: failure.code,
    statusCode: failure.statusCode
  }
}

Do not log body, headers, or cause indiscriminately: those fields can contain cookies, tokens, request data, or adapter details even though the error's serialized form redacts them.

auth.raw is an escape hatch for Better Auth's $context, $ERROR_CODES, options, endpoint metadata, or another API the adapter intentionally does not reinterpret. Normal data, responses, headers, and cookies do not require raw.

Testing and limits

Use ordinary better-effect Layers for local replacements rather than module mocking or global resets. This executable example replaces only the handler:

import { Layer } from 'better-effect'

const liveRuntime = await Runtime.make(Auth.layer)
const liveResult = await liveRuntime.run(
  Effect.fn(async function* () {
    return Result.ok(yield* Auth)
  })
)
await liveRuntime.dispose()

if (Result.isError(liveResult)) throw liveResult.error

const AuthTest = Layer.succeed(
  Auth,
  Auth.of({
    ...liveResult.value,
    handle: async function* () {
      return new Response(JSON.stringify({ source: 'test' }), {
        headers: { 'content-type': 'application/json' }
      })
    }
  })
)
const testRuntime = await Runtime.make(AuthTest)
const testResult = await testRuntime.run(
  Effect.fn(async function* () {
    const auth = yield* Auth
    const response = yield* auth.handle(new Request('https://example.test'))
    return Result.ok(await response.json())
  })
)
await testRuntime.dispose()

void testResult

On this page