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 403 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
    after-admin-user-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
after-admin-user-create.tsdatabaseHooks.user.create.after (filtered)Uservoid
resolve-oauth-context.tsOAuth bearer middlewareOAuth identity + sessionPartial AppContext tenant fields

after-admin-user-create.ts is a virtual event — it wires into the same Better Auth user.create.after slot as the generic after-user-create.ts, but the compiler wraps the call in a runtime filter on the BA context path so it only fires when the row was created via the admin plugin's POST /admin/create-user endpoint. Use it for first-time-user provisioning (welcome email, default org assignment) without inspecting _baCtx?.context?.path by hand. The generic after-user-create.ts still runs for every signup, including admin-created ones.

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).

OAuth context resolver

First-class MCP OAuth validates the access token and live Better Auth session before calling quickback/hooks/resolve-oauth-context.ts. Use this trusted server hook when tenant selection cannot be derived solely from the session's active organization.

quickback/hooks/resolve-oauth-context.ts
export default async ({ token, session, user, context }, { authDb, authSchema, env }) => {
  return {
    activeOrgId: session.activeOrganizationId ?? null,
    activeTeamId: session.activeTeamId ?? null,
    tenantId: session.activeOrganizationId ?? null,
    roles: context.roles,
  };
};

The returned object is merged into the standard AppContext. The compiler always preserves the validated authenticated, userId, and user fields, so this hook cannot replace the OAuth identity.

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