Quickback Docs

Auth Hooks

Run project code on user signup, login, and session events via the quickback/hooks/ folder

Auth Hooks

Quickback discovers project-level auth hooks at quickback/hooks/ and wires them into Better Auth's databaseHooks. Use them for post-signup bootstrap, signup gating, and other lifecycle work.

The classic case is multi-tenant: when account.auth.organizations: true is set, a freshly signed-up user has no activeOrganizationId, so every authenticated /api/v1/* returns 400 ORG_REQUIRED. An after-user-create hook can auto-provision a personal workspace + member row so the next request just works.

Folder layout

quickback/
  hooks/
    after-user-create.ts
    before-user-create.ts
    after-session-create.ts

Each file has a single default export — an async (entity, ctx) => ... function. The filename determines which Better Auth event the hook fires on.

Recognized events

FilenameBetter Auth pathEntityReturn
before-user-create.tsdatabaseHooks.user.create.beforeUservoid | { data?: Partial<User> }
after-user-create.tsdatabaseHooks.user.create.afterUservoid
after-session-create.tsdatabaseHooks.session.create.afterSessionvoid

Filenames outside this set are a hard compile-time error so a typo never silently disables a hook. Add events by extending apps/compiler/src/parser/hooks.ts (and the matching CLI mirror).

ctx shape

Every hook receives (entity, ctx):

type HookCtx = {
  /** Drizzle client bound to the auth DB binding (split-DB aware). */
  authDb: DrizzleClient;
  /**
   * Auth schema namespace — destructure to access the BA-managed tables
   * (`user`, `session`, `organization`, `member`, …) without writing an
   * import path. The hook source lives in `quickback/hooks/` pre-compile
   * and `src/lib/auth-hooks/` post-stage; relying on `authSchema`
   * removes the dual-path import problem.
   */
  authSchema: typeof import('../../auth/schema');
  /** Cloudflare bindings (Cloudflare runtime) or process.env (Bun/Node). */
  env: Bindings;
  /** Better Auth's own context object — request, hook metadata, etc. */
  baContext: any;
};

authDb is the load-bearing piece: hooks must write to the auth DB via Drizzle directly. See the CSRF caveat below. authSchema is the namespace export of the generated auth schema module; destructure the tables you need.

Writing hooks: use built-ins where possible

Hook source files are evaluated by the Better Auth schema-generation step at compile time, which runs in a stripped-down sandbox that does not see your project's node_modules. A hook that imports @paralleldrive/cuid2, nanoid, or any other third-party package will fail compile with a "module not found" error during the auth-schema-generation step — even if the package is installed and runs fine in the deployed Worker.

Stick to runtime built-ins for ID generation, hashing, and similar primitives:

NeedBuilt-in
Random IDcrypto.randomUUID()
SHA-256 / digestcrypto.subtle.digest(...) (Workers / Node 19+)
Random bytescrypto.getRandomValues(new Uint8Array(n))
TimeDate.now(), new Date().toISOString()

drizzle-orm is already available in the schema-gen sandbox (the auth schema imports from it), so eq, and, inArray, etc. are safe to import.

Throw semantics

What happens when a hook throws is the #1 thing that bites people:

HookThrow effect
before-user-createSignup aborts. The user is not created. Throw a typed error to gate.
after-user-createThe user is created (the BA insert already committed). Any derived rows your hook started writing become orphans.
after-session-createSame as above — the session row exists; partial writes leak.

For after-* hooks, design for idempotency: use the entity's id as a stable seed for any derived rows so re-running the hook (manually or via a background reconciler) is safe. Don't generate fresh cuid()s for derived rows you'd want to recover.

Examples

Auto-provision a personal workspace on signup

The QB-9 case. Without this, organizations: true projects break immediately for new users.

quickback/hooks/after-user-create.ts
import { eq } from 'drizzle-orm';

export default async (user, { authDb, authSchema }) => {
  const { organization, member } = authSchema;

  // Idempotency: derive a stable org id from the user id so re-running this
  // hook (after a partial failure) doesn't create a second workspace.
  const orgId = `org_${user.id}`;
  const slug = `${(user.name ?? 'workspace').toLowerCase().replace(/\s+/g, '-')}-${user.id.slice(0, 6)}`;

  // Skip if the workspace already exists (idempotent).
  const existing = await authDb
    .select({ id: organization.id })
    .from(organization)
    .where(eq(organization.id, orgId));
  if (existing.length > 0) return;

  await authDb.insert(organization).values({
    id: orgId,
    name: `${user.name ?? 'Personal'} Workspace`,
    slug,
    createdAt: new Date(),
  });

  await authDb.insert(member).values({
    id: crypto.randomUUID(),
    userId: user.id,
    organizationId: orgId,
    role: 'owner',
    createdAt: new Date(),
  });
};

Set the active organization on next session

Setting activeOrganizationId from a hook means writing to session.active_organization_id via Drizzle directly — calling Better Auth's setActiveOrganization API server-side is awkward (see the CSRF caveat). The reliable pattern:

quickback/hooks/after-session-create.ts
import { eq } from 'drizzle-orm';

export default async (newSession, { authDb, authSchema }) => {
  const { session, member } = authSchema;

  // Pick the user's first membership as their active org. Projects that
  // want a sticky last-active-org should track that separately.
  const memberships = await authDb
    .select({ organizationId: member.organizationId })
    .from(member)
    .where(eq(member.userId, newSession.userId))
    .limit(1);

  if (memberships.length === 0) return;

  await authDb
    .update(session)
    .set({ activeOrganizationId: memberships[0].organizationId })
    .where(eq(session.id, newSession.id));
};

Normalize and gate signup

before-* hooks can return { data: Partial<User> } to mutate the row before insert. Throw to abort.

quickback/hooks/before-user-create.ts
export default async (user: { email: string; name?: string }) => {
  // Block disposable inboxes.
  if (/@(?:mailinator|10minutemail)\.com$/i.test(user.email)) {
    throw new Error('Disposable email addresses are not allowed.');
  }
  // Trim whitespace from display name.
  return { data: { name: user.name?.trim() } };
};

The compiler-generated wrapper merges your data onto the original entity, so you only return the fields you actually want to change.

CSRF caveat — server-side org create

POST /auth/v1/organization/create (and Better Auth's auth.api.createOrganization(...) server helper) require an Origin header. A hook running inside databaseHooks.user.create.after is server-side — there is no incoming request, so no Origin. Calling those APIs from a hook may also validate Origin internally and reject.

The only safe path is direct Drizzle writes via authDb. Treat the org/member tables as your source of truth and let the SPA call the BA API for user-initiated org creation.

Generated output

Compile picks up quickback/hooks/*.ts and produces:

src/lib/auth.ts           # imports the hook + adds a databaseHooks block
src/lib/auth-hooks/<file> # your hook source, copied verbatim

Inside the generated auth.ts:

import __qbHook_afterUserCreate from "./auth-hooks/after-user-create";

// ...inside createAuth(env)...
return betterAuth({
  // ...
  database: drizzleAdapter(db, { /* ... */ }),
  databaseHooks: {
    user: {
      create: {
        after: async (entity, _baCtx) => {
          const _qbCtx = { authDb: db, authSchema: schema, env, baContext: _baCtx };
          await __qbHook_afterUserCreate(entity, _qbCtx);
        },
      },
    },
  },
});

No hooks → no databaseHooks block emitted. The whole feature is opt-in by file presence.

See also

On this page