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.