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 honoThe package's framework-neutral . entry point has these peer requirements:
better-auth^1.7.0better-effect>=0.12.0 <0.14.0better-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.valueThe 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