Better Auth
Better Auth is a framework-agnostic
authentication library. @alchemy.run/better-auth wraps it in one
Effect-native idea: yield* BetterAuth(options) gives you a fully-typed
auth instance, and the database behind it is a Layer you pick per
platform — D1, Neon, Aurora, Hyperdrive, plain Postgres/MySQL, SQLite,
or your own Drizzle db.
import { BetterAuth } from "@alchemy.run/better-auth";import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";import * as Cloudflare from "alchemy/Cloudflare";
export const AuthDb = Cloudflare.D1.Database("AuthDb");
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, Effect.gen(function* () { const auth = yield* BetterAuth({ basePath: "/auth", emailAndPassword: { enabled: true }, });
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; if (request.url.startsWith("/auth")) { return yield* auth.fetch; } const session = yield* auth.getSession(); return yield* HttpServerResponse.json({ user: session?.user ?? null }); }), }; }).pipe(Effect.provide(CloudflareD1(AuthDb))),) {}Deploying this stack creates the D1 database, applies Better Auth’s
schema (an internal alchemy Action — see
Migrations), provisions a stable signing
secret, and serves sign-up/sign-in/session routes under /auth/*.
Typed end to end
Section titled “Typed end to end”BetterAuth(options) preserves Better Auth’s Auth<Options> generic:
the plugins you pass surface as typed endpoints on auth.api, and
session/user types flow from your options.
import { anonymous } from "better-auth/plugins/anonymous";
const auth = yield* BetterAuth({ emailAndPassword: { enabled: true }, plugins: [anonymous()],});
// only exists because anonymous() is in the plugins — and it type-checksconst result = yield* auth.api.signInAnonymous({});Every auth.api.* endpoint is an Effect with a tagged
BetterAuthApiError failure carrying the status key, numeric
statusCode, JSON body (including the machine-readable body.code),
and the response Headers — including the set-cookie values
better-call normally hides on a symbol:
const result = yield* auth.api .signInEmail({ body: { email, password } }) .pipe( Effect.catchTag("BetterAuthApiError", (error) => error.statusCode === 401 ? Effect.succeed(null) // wrong credentials : Effect.fail(error), ), );error.toResponse() renders the failure exactly as Better Auth would
have (status, JSON body, merged headers) for pass-through handlers.
The HTTP surface
Section titled “The HTTP surface”auth.fetch is an alchemy HttpEffect serving the whole Better Auth
route tree — mount it under your basePath. It is host-portable: the
same handler runs on Cloudflare Workers and AWS Lambda function URLs
unchanged.
auth.getSession() reads the session from the ambient request (or an
explicit Headers), resolving null for anonymous requests — failures
are real errors, never missing sessions.
Anything the effectful surface doesn’t cover is reachable through
auth.auth — the raw per-execution Auth instance.
The signing secret
Section titled “The signing secret”secret defaults to an auto-provisioned Alchemy.Random resource:
generated once, persisted in state, stable across deploys, and bound
into the host environment as a secret. Override it with a literal, a
Redacted value, or another resource’s output accessor.
Execution scoping
Section titled “Execution scoping”The Better Auth instance (and any database pool beneath it) is built
once per execution — a Worker event or Lambda invocation — and released
when the event settles. That is the only legal pooling shape on workerd,
and it means Better Auth’s background tasks drain through the event’s
waitUntil automatically.
Where to go next
Section titled “Where to go next”- Database layers — pick the optimal layer for your environment → database pair
- Migrations — how deploy-time schema migration works, and how to opt out