Auth (better-auth)
Authentication is composed, not built in. better-auth is an ordinary in-process library; Twill DB ships a database adapter so the users, sessions, and accounts it manages are stored as plain rows in the embedded engine. Because they are plain rows, auth state inherits everything rows already get — it syncs to object storage, it branches copy-on-write, and it re-warms after scale-to-zero. There is no auth service to run.
The idea
The deciding rule (spec 12): storage and execution capabilities go into the engine; interfaces and services are composed around it and stay optional. Auth is a service capability, so it composes — better-auth runs in your process and writes through an adapter, rather than being welded into the engine core.
The adapter (@twilldb/bun/better-auth) translates better-auth's CRUD contract onto the engine's SQL subset. A session check is then a local function call against an in-process database — no network hop, no separate auth server to deploy or scale.
A library, not a bundled service
better-auth is a dependency you add to your app, not part of libengine. The engine has no notion of users or sessions; it only sees rows. Swap better-auth for another library and the engine is unchanged.
Wire it up
Pass the Twill adapter straight to betterAuth({ database }). The adapter creates its tables on first use (one CREATE TABLE per better-auth model, in autocommit), so no migration step is required for the embedded path.
import { betterAuth } from "better-auth";
import { open } from "@twilldb/bun";
import { twillAdapter } from "@twilldb/bun/better-auth";
using db = open("file://./app.db"); // or s3://bucket/app for disaggregated
const auth = betterAuth({
database: twillAdapter(db),
emailAndPassword: { enabled: true },
secret: process.env.BETTER_AUTH_SECRET!,
});
// Sign-up writes the user, account, and session rows into the engine.
await auth.api.signUpEmail({
body: { email: "[email protected]", password: "correct-horse-battery", name: "Ada" },
});
// Sign-in verifies the hash and mints a session token.
const { token } = await auth.api.signInEmail({
body: { email: "[email protected]", password: "correct-horse-battery" },
});
The auth state is just rows — you can read it with ordinary SQL. Resolving a session token is the two-step lookup an in-process check performs:
const [{ userId }] = db.query("SELECT userId FROM session WHERE token = ?", [token]);
const [{ name }] = db.query("SELECT name FROM user WHERE id = ?", [userId]);
A runnable version of this is clients/bun/examples/auth-app.ts (bun run examples/auth-app.ts).
Auth state branches with the database
Because users and sessions are ordinary rows, branching the database branches the auth state. A branch sees the base's committed users (read-through below the fork LSN) but writes in isolation — a sign-up on a branch never leaks back to the base.
using staging = db.branch("staging");
const stagingAuth = betterAuth({ database: twillAdapter(staging), /* …same config… */ });
// A user created on the branch is invisible to the base.
await stagingAuth.api.signUpEmail({
body: { email: "[email protected]", password: "staging-only", name: "Bel" },
});
// db (base) still shows only [email protected]; staging shows both.
This is the property the composition test asserts (clients/bun/test/better-auth.test.ts): the same database guarantees that protect your rows protect your auth state for free.
How the adapter maps to the SQL subset
Twill DB's SQL surface is a focused subset, and the adapter translates better-auth's query contract onto it. better-auth handles id generation, password hashing, and date/boolean field transforms; the adapter only translates queries and coerces row values back from the engine's text representation.
| better-auth needs | Engine reality | Adapter strategy |
|---|---|---|
in / not_in | No native IN | Expand to an OR / AND chain of equalities |
contains / starts_with / ends_with | LIKE | Bind a %-wrapped pattern |
offset | No OFFSET | Over-fetch by the offset, slice client-side |
limit (dynamic) | LIMIT needs an integer literal | Emit the integer directly, never a parameter |
| Dates, booleans, JSON | Six storage classes (text/int/real/blob/null/vector) | supports*: false — better-auth serializes them; the adapter maps types to affinity |
Scope follows the engine
The adapter is as capable as the engine's SQL subset. Joins, group-by, and subqueries are out of scope by design, so plugins that depend on database-side joins are not supported through this adapter. The core email/password and session flows — and anything expressible as single-table CRUD — work.