Skip to content

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/*.

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-checks
const 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.

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.

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.

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.

  • Database layers — pick the optimal layer for your environment → database pair
  • Migrations — how deploy-time schema migration works, and how to opt out