Pattern: full accounts (public sign-up, memberships, paywalls) with better-auth
When to reach for this. End-user-facing auth at scale — public sign-up,
memberships, a paywall, password resets, account linking, and a plugin ecosystem
(social, OTP, passkey, 2FA, organizations). When the lightweight session of
unfolder.auth-sessions isn't enough and you want the account lifecycle managed
for you.
The stance. Use better-auth over the facet's durable-sqlite. Every facet
boots with nodejs_compat — and the build resolves packages the same way — so
better-auth's node:* API needs are satisfied and its email/password path runs
in-facet with the real node:crypto scrypt. Two platform-specific things you must get
right: the facet owns its DDL (there's no drizzle-kit in the sandbox), and its
session-signing secret is minted-and-persisted in facet KV on first boot (it's
an internal cookie key, not a credential the owner provides).
Dependencies + schema
{ "name": "my-space", "main": "src/index.ts",
"dependencies": { "better-auth": "1.6.11", "drizzle-orm": "0.45.2" } }
src/schema.ts — better-auth's four core tables (the names/columns the Drizzle
adapter expects for provider: "sqlite"):
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"), refreshToken: text("refresh_token"), idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
scope: text("scope"), password: text("password"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }),
updatedAt: integer("updated_at", { mode: "timestamp" }),
});
The worker — own the DDL, mint the secret, hand it the facet DB
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/durable-sqlite";
import { DurableObject } from "cloudflare:workers";
import * as schema from "./schema";
const DDL = [
"CREATE TABLE IF NOT EXISTS user (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, email_verified INTEGER NOT NULL, image TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE)",
"CREATE TABLE IF NOT EXISTS account (id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS verification (id TEXT PRIMARY KEY, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER, updated_at INTEGER)",
];
export class App extends DurableObject {
db = drizzle(this.ctx.storage, { schema });
#secret;
#auth;
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
for (const stmt of DDL) ctx.storage.sql.exec(stmt);
// A facet has no ambient env binding for a signing key, so mint one on first
// boot and keep it in the facet's own KV: stable across production redeploys.
// Never hardcode a secret.
let secret = ctx.storage.kv.get("auth_secret");
if (!secret) { secret = crypto.randomUUID() + crypto.randomUUID(); ctx.storage.kv.put("auth_secret", secret); }
this.#secret = secret;
});
}
// baseURL must match the arriving host so better-auth's /api/auth/* + origin checks line up.
#authFor(origin) {
this.#auth ??= betterAuth({
baseURL: origin, trustedOrigins: [origin], secret: this.#secret,
database: drizzleAdapter(this.db, { provider: "sqlite" }),
emailAndPassword: { enabled: true },
});
return this.#auth;
}
async fetch(request) {
const url = new URL(request.url);
const auth = this.#authFor(url.origin);
if (url.pathname.startsWith("/api/auth/")) return auth.handler(request); // sign-up, sign-in, …
const sess = await auth.api.getSession({ headers: request.headers });
if (!sess) return new Response("Sign in required", { status: 401 });
return Response.json({ hello: sess.user.email });
}
}
Clients hit POST /api/auth/sign-up/email and POST /api/auth/sign-in/email
({ email, password, name }), then ride the session cookie. Owner-only admin
is identity + a check: the first account to sign up is the owner, then compare the
signed-in user in your gated handlers.
Social, OTP, passkey — config, not a new library
Social login is a socialProviders block; the same account table holds the
linked provider. The same platform rules as unfolder.auth-sessions apply:
clientId → variable, clientSecret → secret, and allowEgress the provider's
token host (the callback's token exchange is default-deny outbound).
betterAuth({
// …database, secret, baseURL as above…
emailAndPassword: { enabled: true },
socialProviders: {
google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET },
},
});
OTP, passkey, 2FA, organizations, and admin wire in the same way — see the
better-auth docs and its client SDK. If you only need "sign in with Google" + a
simple session (no account lifecycle), unfolder.auth-sessions is lighter.